From 99574f85a3a5250ebfe6accfb6cc80c149f04df3 Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Mon, 16 Nov 2020 16:47:55 -0500 Subject: [PATCH 001/129] Add a command to delete a gist (#2265) * Add a command to delete a gist * minor cleanup Co-authored-by: vilmibm --- pkg/cmd/gist/delete/delete.go | 88 ++++++++++++++++ pkg/cmd/gist/delete/delete_test.go | 156 +++++++++++++++++++++++++++++ pkg/cmd/gist/gist.go | 2 + 3 files changed, 246 insertions(+) create mode 100644 pkg/cmd/gist/delete/delete.go create mode 100644 pkg/cmd/gist/delete/delete_test.go diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go new file mode 100644 index 000000000..03ddc6e46 --- /dev/null +++ b/pkg/cmd/gist/delete/delete.go @@ -0,0 +1,88 @@ +package delete + +import ( + "fmt" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "net/http" + "strings" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Selector string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete { | }", + Short: "Delete a gist", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + opts.Selector = args[0] + if runF != nil { + return runF(&opts) + } + return deleteRun(&opts) + }, + } + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + gistID := opts.Selector + + if strings.Contains(gistID, "/") { + id, err := shared.GistIDFromURL(gistID) + if err != nil { + return err + } + gistID = id + } + client, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(client) + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + username, err := api.CurrentLoginName(apiClient, ghinstance.OverridableDefault()) + if err != nil { + return err + } + + if username != gist.Owner.Login { + return fmt.Errorf("You do not own this gist.") + } + + err = deleteGist(apiClient, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + return nil +} + +func deleteGist(apiClient *api.Client, hostname string, gistID string) error { + path := "gists/" + gistID + err := apiClient.REST(hostname, "DELETE", path, nil, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go new file mode 100644 index 000000000..682a92d50 --- /dev/null +++ b/pkg/cmd/gist/delete/delete_test.go @@ -0,0 +1,156 @@ +package delete + +import ( + "bytes" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + wants DeleteOptions + }{ + { + name: "valid selector", + cli: "123", + wants: DeleteOptions{ + Selector: "123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + }) + } +} + +func Test_deleteRun(t *testing.T) { + tests := []struct { + name string + opts *DeleteOptions + gist *shared.Gist + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + nontty bool + wantErr bool + wantStderr string + wantParams map[string]interface{} + }{ + { + name: "no such gist", + wantErr: true, + }, { + name: "another user's gist", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + Owner: &shared.GistOwner{Login: "octocat2"}, + }, + wantErr: true, + wantStderr: "You do not own this gist.", + }, { + name: "successfully delete", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("DELETE", "gists/1234"), + httpmock.StatusStringResponse(200, "{}")) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + reg.Register(httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + if tt.opts == nil { + tt.opts = &DeleteOptions{} + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + io.SetStdinTTY(!tt.nontty) + tt.opts.IO = io + tt.opts.Selector = "1234" + + t.Run(tt.name, func(t *testing.T) { + err := deleteRun(tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + if tt.wantStderr != "" { + assert.EqualError(t, err, tt.wantStderr) + } + return + } + assert.NoError(t, err) + + }) + } +} diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index 9a4d42b87..0abfc8591 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -3,6 +3,7 @@ package gist import ( "github.com/MakeNowJust/heredoc" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + gistDeleteCmd "github.com/cli/cli/pkg/cmd/gist/delete" gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit" gistListCmd "github.com/cli/cli/pkg/cmd/gist/list" gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view" @@ -29,6 +30,7 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) + cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil)) return cmd } From 0bb44c9cedcdf03cff43e820cbfba523c81dad58 Mon Sep 17 00:00:00 2001 From: Christopher Oswald <8537054+cesoun@users.noreply.github.com> Date: Mon, 16 Nov 2020 15:01:30 -0700 Subject: [PATCH 002/129] Support for --web when using gist create (#2263) * Support for --web when using gist create Proposal to close #2071 I have not worked with Go prior to this so please smite me down with the wisdom of a million Golang gods if I'm doing something terribly wrong. I also added a test to gist/create for the added web arg. Pretty much referenced the implementation from pr/create. * Fix for Tests / build (windows-latest) I believe this fixes it as it stopped failing on a local vm. Otherwise I will try and tackle it tomorrow. * minor cleanup Co-authored-by: vilmibm --- pkg/cmd/gist/create/create.go | 9 ++++++++ pkg/cmd/gist/create/create_test.go | 34 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index dc7d4b257..df12e8702 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -30,6 +30,8 @@ type CreateOptions struct { Filenames []string FilenameOverride string + WebMode bool + HttpClient func() (*http.Client, error) } @@ -86,6 +88,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser with created gist") cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)") cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN") return cmd @@ -138,6 +141,12 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(errOut, "%s %s\n", cs.SuccessIcon(), completionMessage) + if opts.WebMode { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(gist.HTMLURL)) + + return utils.OpenInBrowser(gist.HTMLURL) + } + fmt.Fprintln(opts.IO.Out, gist.HTMLURL) return nil diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index dd1912909..548abd743 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -3,6 +3,7 @@ package create import ( "bytes" "encoding/json" + "github.com/cli/cli/test" "io/ioutil" "net/http" "strings" @@ -254,6 +255,26 @@ func Test_createRun(t *testing.T) { }, }, }, + { + name: "web arg", + opts: &CreateOptions{ + WebMode: true, + Filenames: []string{fixtureFile}, + }, + wantOut: "Opening gist.github.com/aa5a315d61ae9438b18d in your browser.\n", + wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n", + wantErr: false, + wantParams: map[string]interface{}{ + "description": "", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "fixture.txt": map[string]interface{}{ + "content": "{}", + }, + }, + }, + }, } for _, tt := range tests { reg := &httpmock.Registry{} @@ -270,6 +291,13 @@ func Test_createRun(t *testing.T) { io, stdin, stdout, stderr := iostreams.Test() tt.opts.IO = io + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + if tt.opts.WebMode { + cs.Stub("") + } + t.Run(tt.name, func(t *testing.T) { stdin.WriteString(tt.stdin) @@ -285,6 +313,12 @@ func Test_createRun(t *testing.T) { assert.Equal(t, tt.wantOut, stdout.String()) assert.Equal(t, tt.wantStderr, stderr.String()) assert.Equal(t, tt.wantParams, reqBody) + + if tt.opts.WebMode { + browserCall := cs.Calls[0].Args + assert.Equal(t, browserCall[len(browserCall)-1], "https://gist.github.com/aa5a315d61ae9438b18d") + } + reg.Verify(t) }) } From 00617216b8fc12dceb938371159903e6b1a993ac Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 16 Nov 2020 14:03:52 -0800 Subject: [PATCH 003/129] fix missing import --- pkg/cmd/gist/create/create.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index df12e8702..ee2976b80 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" "github.com/spf13/cobra" ) From 7f57c1c3f238c3dff75710f23e4968fbdcaa803c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 17 Nov 2020 11:25:07 +0300 Subject: [PATCH 004/129] Downgrade survey to v2.1.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 978085109..ec44ad44e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.2.2 + github.com/AlecAivazis/survey/v2 v2.1.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 diff --git a/go.sum b/go.sum index eb7d0ae52..fa1ef7c15 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AlecAivazis/survey/v2 v2.2.2 h1:1I4qBrNsHQE+91tQCqVlfrKe9DEL65949d1oKZWVELY= -github.com/AlecAivazis/survey/v2 v2.2.2/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= +github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI= +github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= From b205faa9412e2a18a79cd4a93cbe860b904e134b Mon Sep 17 00:00:00 2001 From: Jakub Warczarek Date: Tue, 17 Nov 2020 19:27:07 +0100 Subject: [PATCH 005/129] Implement --web for gh pr checks (#2146) --- pkg/cmd/pr/checks/checks.go | 23 +++++++++--- pkg/cmd/pr/checks/checks_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index f93cbe6d1..89eb8e5d3 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -24,6 +24,8 @@ type ChecksOptions struct { Branch func() (string, error) Remotes func() (context.Remotes, error) + WebMode bool + SelectorArg string } @@ -60,6 +62,8 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co }, } + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks") + return cmd } @@ -70,7 +74,7 @@ func checksRun(opts *ChecksOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) if err != nil { return err } @@ -84,6 +88,16 @@ func checksRun(opts *ChecksOptions) error { return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName) } + isTerminal := opts.IO.IsStdoutTTY() + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(baseRepo, "pull/%d/checks", pr.Number) + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + passing := 0 failing := 0 pending := 0 @@ -164,9 +178,8 @@ func checksRun(opts *ChecksOptions) error { if b0 == b1 { if n0 == n1 { return l0 < l1 - } else { - return n0 < n1 } + return n0 < n1 } return (b0 == "fail") || (b0 == "pending" && b1 == "success") @@ -175,7 +188,7 @@ func checksRun(opts *ChecksOptions) error { tp := utils.NewTablePrinter(opts.IO) for _, o := range outputs { - if opts.IO.IsStdoutTTY() { + if isTerminal { tp.AddField(o.mark, nil, o.markColor) tp.AddField(o.name, nil, nil) tp.AddField(o.elapsed, nil, nil) @@ -211,7 +224,7 @@ func checksRun(opts *ChecksOptions) error { summary = fmt.Sprintf("%s\n%s", cs.Bold(summary), tallies) } - if opts.IO.IsStdoutTTY() { + if isTerminal { fmt.Fprintln(opts.IO.Out, summary) fmt.Fprintln(opts.IO.Out) } diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 324f8a74f..4e31eade6 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -200,3 +201,63 @@ func Test_checksRun(t *testing.T) { }) } } + +func TestChecksRun_web(t *testing.T) { + tests := []struct { + name string + isTTY bool + wantStderr string + wantStdout string + }{ + { + name: "tty", + isTTY: true, + wantStderr: "Opening github.com/OWNER/REPO/pull/123/checks in your browser.\n", + wantStdout: "", + }, + { + name: "nontty", + isTTY: false, + wantStderr: "", + wantStdout: "", + }, + } + + reg := &httpmock.Registry{} + + opts := &ChecksOptions{ + WebMode: true, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + SelectorArg: "123", + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse("./fixtures/allPassing.json")) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tc.isTTY) + io.SetStdinTTY(tc.isTTY) + io.SetStderrTTY(tc.isTTY) + + opts.IO = io + + cs, teardown := test.InitCmdStubber() + defer teardown() + + cs.Stub("") // browser open + + err := checksRun(opts) + assert.NoError(t, err) + assert.Equal(t, tc.wantStdout, stdout.String()) + assert.Equal(t, tc.wantStderr, stderr.String()) + reg.Verify(t) + }) + } +} From e87b5bcaff227aad0118532d74decc90e8528723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Risti=C4=87?= Date: Wed, 18 Nov 2020 19:31:36 +0100 Subject: [PATCH 006/129] Add "reference" help topic (#2223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add "reference" help topic * Only print reference as a help topic * fix for color fns, slightly generalize * WIP for switching to markdown * escape gt/lt * minor * higher wrap point * detect terminal theme * futz with angle brackets once more * minor cleanup * prepend parent commands * rename help topic fns and add test * Simplify reference help generation - the `<...>` characters from command usage line are now preserved by enclosing the entire usage synopsis in a code span - hard breaks in flag usage lines are preserved by enclosing flag usage in a code block - TTY detection and Markdown rendering are now delayed until the user explicitly requests `gh help reference` - `gh help reference` output is now pager-enabled Co-authored-by: vilmibm Co-authored-by: vilmibm Co-authored-by: Mislav Marohnić --- pkg/cmd/root/help_reference.go | 69 +++++++++++++++++++++++++++++++++ pkg/cmd/root/help_topic.go | 5 ++- pkg/cmd/root/help_topic_test.go | 4 +- pkg/cmd/root/root.go | 5 +++ pkg/markdown/markdown.go | 17 ++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/root/help_reference.go diff --git a/pkg/cmd/root/help_reference.go b/pkg/cmd/root/help_reference.go new file mode 100644 index 000000000..ecc7ec856 --- /dev/null +++ b/pkg/cmd/root/help_reference.go @@ -0,0 +1,69 @@ +package root + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/markdown" + "github.com/spf13/cobra" +) + +func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + wrapWidth := 0 + style := "notty" + if io.IsStdoutTTY() { + wrapWidth = io.TerminalWidth() + style = markdown.GetStyle(io.DetectTerminalTheme()) + } + + md, err := markdown.RenderWrap(cmd.Long, style, wrapWidth) + if err != nil { + fmt.Fprintln(io.ErrOut, err) + return + } + + if !io.IsStdoutTTY() { + fmt.Fprint(io.Out, dedent(md)) + return + } + + _ = io.StartPager() + defer io.StopPager() + fmt.Fprint(io.Out, md) + } +} + +func referenceLong(cmd *cobra.Command) string { + buf := bytes.NewBufferString("# gh reference\n\n") + for _, c := range cmd.Commands() { + if c.Hidden { + continue + } + cmdRef(buf, c, 2) + } + return buf.String() +} + +func cmdRef(w io.Writer, cmd *cobra.Command, depth int) { + // Name + Description + fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine()) + fmt.Fprintf(w, "%s\n\n", cmd.Short) + + // Flags + // TODO: fold in InheritedFlags/PersistentFlags, but omit `--help` due to repetitiveness + if flagUsages := cmd.Flags().FlagUsages(); flagUsages != "" { + fmt.Fprintf(w, "```\n%s````\n\n", dedent(flagUsages)) + } + + // Subcommands + for _, c := range cmd.Commands() { + if c.Hidden { + continue + } + cmdRef(w, c, depth+1) + } +} diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index c44bfca00..99055034b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -47,6 +47,9 @@ var HelpTopics = map[string]map[string]string{ error if a newer version was found. `), }, + "reference": { + "short": "A comprehensive reference of all gh commands", + }, } func NewHelpTopic(topic string) *cobra.Command { @@ -55,8 +58,6 @@ func NewHelpTopic(topic string) *cobra.Command { Short: HelpTopics[topic]["short"], Long: HelpTopics[topic]["long"], Hidden: true, - Args: cobra.NoArgs, - Run: helpTopicHelpFunc, Annotations: map[string]string{ "markdown:generate": "true", "markdown:basename": "gh_help_" + topic, diff --git a/pkg/cmd/root/help_topic_test.go b/pkg/cmd/root/help_topic_test.go index f194541ac..3aba4bdf3 100644 --- a/pkg/cmd/root/help_topic_test.go +++ b/pkg/cmd/root/help_topic_test.go @@ -34,7 +34,7 @@ func TestNewHelpTopic(t *testing.T) { topic: "environment", args: []string{"invalid"}, flags: []string{}, - wantsErr: true, + wantsErr: false, }, { name: "more than zero flags", @@ -48,7 +48,7 @@ func TestNewHelpTopic(t *testing.T) { topic: "environment", args: []string{"help"}, flags: []string{}, - wantsErr: true, + wantsErr: false, }, { name: "help flag", diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 906223d15..93cb4431d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -92,9 +92,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { // Help topics cmd.AddCommand(NewHelpTopic("environment")) + referenceCmd := NewHelpTopic("reference") + referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams)) + cmd.AddCommand(referenceCmd) cmdutil.DisableAuthCheck(cmd) + // this needs to appear last: + referenceCmd.Long = referenceLong(cmd) return cmd } diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index 844e06811..505c7d401 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -24,6 +24,23 @@ func Render(text, style string, baseURL string) (string, error) { return tr.Render(text) } +func RenderWrap(text, style string, wrap int) (string, error) { + // Glamour rendering preserves carriage return characters in code blocks, but + // we need to ensure that no such characters are present in the output. + text = strings.ReplaceAll(text, "\r\n", "\n") + + tr, err := glamour.NewTermRenderer( + glamour.WithStylePath(style), + // glamour.WithBaseURL(""), // TODO: make configurable + glamour.WithWordWrap(wrap), + ) + if err != nil { + return "", err + } + + return tr.Render(text) +} + func GetStyle(defaultStyle string) string { style := fromEnv() if style != "" && style != "auto" { From c7eb57d443a392629f7cda31b34b918acad00925 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 19 Nov 2020 12:59:18 -0600 Subject: [PATCH 007/129] respect GH_HOST when resolving remotes (#2301) * vim to gitignore * respect GH_HOST in Resolver * slight restructure, add a test * grammar fix --- .gitignore | 3 +++ pkg/cmd/factory/default.go | 9 ++++++- pkg/cmd/factory/remote_resolver.go | 19 ++++++++++++++- pkg/cmd/factory/remote_resolver_test.go | 31 ++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 00a5bb5a6..9895939a6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ # macOS .DS_Store +# vim +*.swp + vendor/ diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index ae8622b2a..2c70ab5cd 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -31,11 +33,16 @@ func New(appVersion string) *cmdutil.Factory { return cachedConfig, configError } + hostOverride := "" + if !strings.EqualFold(ghinstance.Default(), ghinstance.OverridableDefault()) { + hostOverride = ghinstance.OverridableDefault() + } + rr := &remoteResolver{ readRemotes: git.Remotes, getConfig: configFunc, } - remotesFunc := rr.Resolver() + remotesFunc := rr.Resolver(hostOverride) return &cmdutil.Factory{ IOStreams: io, diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go index 589b19959..ee603e49d 100644 --- a/pkg/cmd/factory/remote_resolver.go +++ b/pkg/cmd/factory/remote_resolver.go @@ -4,6 +4,7 @@ import ( "errors" "net/url" "sort" + "strings" "github.com/cli/cli/context" "github.com/cli/cli/git" @@ -17,7 +18,7 @@ type remoteResolver struct { urlTranslator func(*url.URL) *url.URL } -func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { +func (rr *remoteResolver) Resolver(hostOverride string) func() (context.Remotes, error) { var cachedRemotes context.Remotes var remotesError error @@ -59,6 +60,22 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { var hostname string cachedRemotes = context.Remotes{} sort.Sort(resolvedRemotes) + + if hostOverride != "" { + for _, r := range resolvedRemotes { + if strings.EqualFold(r.RepoHost(), hostOverride) { + cachedRemotes = append(cachedRemotes, r) + } + } + + if len(cachedRemotes) == 0 { + remotesError = errors.New("none of the git remotes configured for this repository correspond to the GH_HOST environment variable. Try adding a matching remote or unsetting the variable.") + return nil, remotesError + } + + return cachedRemotes, nil + } + for _, r := range resolvedRemotes { if hostname == "" { if !knownHosts[r.RepoHost()] { diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go index d0337499c..27c937e36 100644 --- a/pkg/cmd/factory/remote_resolver_test.go +++ b/pkg/cmd/factory/remote_resolver_test.go @@ -32,7 +32,7 @@ func Test_remoteResolver(t *testing.T) { }, } - resolver := rr.Resolver() + resolver := rr.Resolver("") remotes, err := resolver() require.NoError(t, err) require.Equal(t, 2, len(remotes)) @@ -40,3 +40,32 @@ func Test_remoteResolver(t *testing.T) { assert.Equal(t, "upstream", remotes[0].Name) assert.Equal(t, "fork", remotes[1].Name) } + +func Test_remoteResolverOverride(t *testing.T) { + rr := &remoteResolver{ + readRemotes: func() (git.RemoteSet, error) { + return git.RemoteSet{ + git.NewRemote("fork", "https://example.org/ghe-owner/ghe-fork.git"), + git.NewRemote("origin", "https://github.com/owner/repo.git"), + git.NewRemote("upstream", "https://example.org/ghe-owner/ghe-repo.git"), + }, nil + }, + getConfig: func() (config.Config, error) { + return config.NewFromString(heredoc.Doc(` + hosts: + example.org: + oauth_token: GHETOKEN + `)), nil + }, + urlTranslator: func(u *url.URL) *url.URL { + return u + }, + } + + resolver := rr.Resolver("github.com") + remotes, err := resolver() + require.NoError(t, err) + require.Equal(t, 1, len(remotes)) + + assert.Equal(t, "origin", remotes[0].Name) +} From 05a1a252712e529a385c850bab501590a0156526 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Fri, 20 Nov 2020 12:00:49 -0600 Subject: [PATCH 008/129] match parent repo protocol when forking (#2434) * match parent repo protocol when forking * guard against nil and prefer PushURL --- pkg/cmd/repo/fork/fork.go | 15 +++++++++ pkg/cmd/repo/fork/fork_test.go | 56 +++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index ebaf6c400..760bb6537 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -195,6 +195,21 @@ func forkRun(opts *ForkOptions) error { if err != nil { return err } + + if remote, err := remotes.FindByRepo(repoToFork.RepoOwner(), repoToFork.RepoName()); err == nil { + + scheme := "" + if remote.FetchURL != nil { + scheme = remote.FetchURL.Scheme + } + if remote.PushURL != nil { + scheme = remote.PushURL.Scheme + } + if scheme != "" { + protocol = scheme + } + } + if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil { if connectedToTerminal { fmt.Fprintf(stderr, "%s Using existing remote %s\n", cs.SuccessIcon(), cs.Bold(remote.Name)) diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 0a92b77e5..20f396394 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -2,6 +2,7 @@ package fork import ( "net/http" + "net/url" "os/exec" "regexp" "strings" @@ -44,8 +45,11 @@ func runCommand(httpClient *http.Client, remotes []*context.Remote, isTTY bool, if remotes == nil { return []*context.Remote{ { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), + Remote: &git.Remote{ + Name: "origin", + FetchURL: &url.URL{}, + }, + Repo: ghrepo.New("OWNER", "REPO"), }, }, nil } @@ -179,11 +183,11 @@ func TestRepoFork_reuseRemote(t *testing.T) { stubSpinner() remotes := []*context.Remote{ { - Remote: &git.Remote{Name: "origin"}, + Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}}, Repo: ghrepo.New("someone", "REPO"), }, { - Remote: &git.Remote{Name: "upstream"}, + Remote: &git.Remote{Name: "upstream", FetchURL: &url.URL{}}, Repo: ghrepo.New("OWNER", "REPO"), }, } @@ -465,6 +469,50 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) { reg.Verify(t) } +func TestRepoFork_in_parent_match_protocol(t *testing.T) { + stubSpinner() + defer stubSince(2 * time.Second)() + reg := &httpmock.Registry{} + defer reg.StubWithFixturePath(200, "./forkResult.json")() + httpClient := &http.Client{Transport: reg} + + var seenCmds []*exec.Cmd + defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmds = append(seenCmds, cmd) + return &test.OutputStub{} + })() + + remotes := []*context.Remote{ + { + Remote: &git.Remote{Name: "origin", PushURL: &url.URL{ + Scheme: "ssh", + }}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + } + + output, err := runCommand(httpClient, remotes, true, "--remote") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + expectedCmds := []string{ + "git remote rename origin upstream", + "git remote add -f origin git@github.com:someone/REPO.git", + } + + for x, cmd := range seenCmds { + assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " ")) + } + + assert.Equal(t, "", output.String()) + + test.ExpectLines(t, output.Stderr(), + "Created fork.*someone/REPO", + "Added remote.*origin") + reg.Verify(t) +} + func stubSpinner() { // not bothering with teardown since we never want spinners when doing tests utils.StartSpinner = func(_ *spinner.Spinner) { From 67672fa88c944b6118daa2b2c5dbcb82b7b84791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 20 Nov 2020 19:36:04 +0100 Subject: [PATCH 009/129] Prime user's git HTTPS credentials on `auth login` --- pkg/cmd/auth/login/login.go | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index d702d0a1a..e6f9db9b5 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -1,6 +1,7 @@ package login import ( + "bytes" "errors" "fmt" "io/ioutil" @@ -9,9 +10,11 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" + "github.com/cli/cli/git" "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmd/auth/client" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -259,7 +262,7 @@ func loginRun(opts *LoginOptions) error { cs := opts.IO.ColorScheme() - gitProtocol := "https" + var gitProtocol string if opts.Interactive { err = prompt.SurveyAskOne(&survey.Select{ Message: "Choose default git protocol", @@ -303,6 +306,37 @@ func loginRun(opts *LoginOptions) error { return err } + if opts.Interactive && gitProtocol == "https" { + var primeCredentials bool + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Set up git for passwordless push/pull operations?", + Default: true, + }, &primeCredentials) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + if primeCredentials { + gitCredential, err := git.GitCommand("credential", "approve") + if err != nil { + return err + } + credentialStdin := &bytes.Buffer{} + gitCredential.Stdin = credentialStdin + + password, _ := cfg.Get(hostname, "oauth_token") + fmt.Fprint(credentialStdin, "protocol=https\n") + fmt.Fprintf(credentialStdin, "host=%s\n", hostname) + fmt.Fprintf(credentialStdin, "username=%s\n", username) + fmt.Fprintf(credentialStdin, "password=%s\n", password) + fmt.Fprint(credentialStdin, "\n") + + err = run.PrepareCmd(gitCredential).Run() + if err != nil { + return err + } + } + } + fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username)) return nil From 91d2adc13442177cec7556a57dc0d4def2c0d550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 20 Nov 2020 19:36:26 +0100 Subject: [PATCH 010/129] Avoid re-requesting username if we already have it --- pkg/cmd/auth/login/login.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index e6f9db9b5..4246d5d8b 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -229,11 +229,13 @@ func loginRun(opts *LoginOptions) error { } } + userValidated := false if authMode == 0 { _, err := authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", opts.Scopes) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } + userValidated = true } else { fmt.Fprintln(opts.IO.ErrOut) fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(getAccessTokenTip(hostname))) @@ -286,19 +288,24 @@ func loginRun(opts *LoginOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon()) } - apiClient, err := client.ClientFromCfg(hostname, cfg) - if err != nil { - return err - } + var username string + if userValidated { + username, _ = cfg.Get(hostname, "user") + } else { + apiClient, err := client.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } - username, err := api.CurrentLoginName(apiClient, hostname) - if err != nil { - return fmt.Errorf("error using api: %w", err) - } + username, err = api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("error using api: %w", err) + } - err = cfg.Set(hostname, "user", username) - if err != nil { - return err + err = cfg.Set(hostname, "user", username) + if err != nil { + return err + } } err = cfg.Write() From e36c9029d37e50c51a1f9ea7da6c824af91aa2a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 20 Nov 2020 20:33:08 +0100 Subject: [PATCH 011/129] Fix broken tests --- pkg/cmd/auth/login/login_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 9a6b5aa8b..c41626939 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -342,6 +342,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) @@ -363,6 +364,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) @@ -383,6 +385,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"), }, From e16bf094bdca3c8e5d0486f6e1a9f6e2c3f701c5 Mon Sep 17 00:00:00 2001 From: Ismael Luceno Date: Sat, 21 Nov 2020 23:00:14 +0100 Subject: [PATCH 012/129] Simplify CGO flags setup --- Makefile | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 859461984..05e792609 100644 --- a/Makefile +++ b/Makefile @@ -9,15 +9,12 @@ else BUILD_DATE ?= $(shell date "$(DATE_FMT)") endif -ifndef CGO_CPPFLAGS - export CGO_CPPFLAGS := $(CPPFLAGS) -endif -ifndef CGO_CFLAGS - export CGO_CFLAGS := $(CFLAGS) -endif -ifndef CGO_LDFLAGS - export CGO_LDFLAGS := $(LDFLAGS) -endif +CGO_CPPFLAGS ?= ${CPPFLAGS} +export CGO_CPPFLAGS +CGO_CFLAGS ?= ${CFLAGS} +export CGO_CFLAGS +CGO_LDFLAGS ?= ${LDFLAGS} +export CGO_LDFLAGS GO_LDFLAGS := -X github.com/cli/cli/internal/build.Version=$(GH_VERSION) $(GO_LDFLAGS) GO_LDFLAGS := -X github.com/cli/cli/internal/build.Date=$(BUILD_DATE) $(GO_LDFLAGS) From a4daf96a694b56e7945748e1d392dd746ec0de71 Mon Sep 17 00:00:00 2001 From: Ismael Luceno Date: Sat, 21 Nov 2020 23:00:45 +0100 Subject: [PATCH 013/129] Make bin/gh rule verbose --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 05e792609..dbafea1e4 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ ifdef GH_OAUTH_CLIENT_SECRET endif bin/gh: $(BUILD_FILES) - @go build -trimpath -ldflags "$(GO_LDFLAGS)" -o "$@" ./cmd/gh + go build -trimpath -ldflags "${GO_LDFLAGS}" -o "$@" ./cmd/gh clean: rm -rf ./bin ./share From 0e681ca6c4a43c6f33532917ef995d591375c348 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:50 -0500 Subject: [PATCH 014/129] spelling: beginning --- pkg/cmd/pr/create/regexp_writer_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/create/regexp_writer_test.go b/pkg/cmd/pr/create/regexp_writer_test.go index fd772a760..f9ec0b863 100644 --- a/pkg/cmd/pr/create/regexp_writer_test.go +++ b/pkg/cmd/pr/create/regexp_writer_test.go @@ -93,14 +93,14 @@ func Test_Write(t *testing.T) { { name: "multiple lines removed", input: input{ - in: []string{"begining line\nremove this whole line\nremove this one also\nnot this one"}, + in: []string{"beginning line\nremove this whole line\nremove this one also\nnot this one"}, re: regexp.MustCompile("(?s)^remove.*$"), repl: "", }, output: output{ wantsErr: false, - out: "begining line\nnot this one", - length: 70, + out: "beginning line\nnot this one", + length: 71, }, }, { From e58b2dbe92815222858af49ec0e168a2aae4a253 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:50 -0500 Subject: [PATCH 015/129] spelling: chestnuts --- pkg/cmd/repo/create/http_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go index c8e2e7a2a..360403538 100644 --- a/pkg/cmd/repo/create/http_test.go +++ b/pkg/cmd/repo/create/http_test.go @@ -17,7 +17,7 @@ func Test_RepoCreate(t *testing.T) { reg.StubResponse(200, bytes.NewBufferString(`{}`)) input := repoCreateInput{ - Description: "roasted chesnuts", + Description: "roasted chestnuts", HomepageURL: "http://example.com", } @@ -39,8 +39,8 @@ func Test_RepoCreate(t *testing.T) { bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) _ = json.Unmarshal(bodyBytes, &reqBody) - if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" { - t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description) + if description := reqBody.Variables.Input["description"].(string); description != "roasted chestnuts" { + t.Errorf("expected description to be %q, got %q", "roasted chestnuts", description) } if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) From 8ba68fc68ada852d8468753dd0a2ffb3c357a110 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:50 -0500 Subject: [PATCH 016/129] spelling: deprecated --- internal/docs/man_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go index 4e844ad61..58591e19e 100644 --- a/internal/docs/man_test.go +++ b/internal/docs/man_test.go @@ -106,7 +106,7 @@ func TestGenManSeeAlso(t *testing.T) { } } -func TestManPrintFlagsHidesShortDeperecated(t *testing.T) { +func TestManPrintFlagsHidesShortDeprecated(t *testing.T) { c := &cobra.Command{} c.Flags().StringP("foo", "f", "default", "Foo flag") _ = c.Flags().MarkShorthandDeprecated("foo", "don't use it no more") From ddd438d5e14ce696298f684e24e6933b3fe49548 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:50 -0500 Subject: [PATCH 017/129] spelling: dismissed --- pkg/cmd/pr/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 1bb0eff1b..9dcdb8f8b 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -214,7 +214,7 @@ type reviewerState struct { func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string { state := reviewer.State if state == dismissedReviewState { - // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes + // Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes // sense when displayed in an events timeline but not in the final tally. state = commentedReviewState } From 861d350440ca0ba866c4651351b29afb939124a2 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:50 -0500 Subject: [PATCH 018/129] spelling: dunno --- pkg/cmd/pr/review/review_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index b96ab8089..2184c264c 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -319,7 +319,7 @@ func TestPRReview(t *testing.T) { {`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"}, {`--approve`, "APPROVE", ""}, {`--approve -b"hot damn"`, "APPROVE", "hot damn"}, - {`--comment --body "i donno"`, "COMMENT", "i donno"}, + {`--comment --body "i dunno"`, "COMMENT", "i dunno"}, } for _, kase := range cases { From 76bd3772530f09de9b2d40f480c63a6d6427f410 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:51 -0500 Subject: [PATCH 019/129] spelling: error --- pkg/cmd/root/help.go | 2 +- pkg/cmd/root/root.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index a3be5a27a..80b61341e 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -35,7 +35,7 @@ func rootUsageFunc(command *cobra.Command) error { return nil } -func rootFlagErrrorFunc(cmd *cobra.Command, err error) error { +func rootFlagErrorFunc(cmd *cobra.Command, err error) error { if err == pflag.ErrHelp { return err } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 93cb4431d..42c1b627f 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -59,7 +59,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.PersistentFlags().Bool("help", false, "Show help for command") cmd.SetHelpFunc(helpHelper) cmd.SetUsageFunc(rootUsageFunc) - cmd.SetFlagErrorFunc(rootFlagErrrorFunc) + cmd.SetFlagErrorFunc(rootFlagErrorFunc) formattedVersion := versionCmd.Format(version, buildDate) cmd.SetVersionTemplate(formattedVersion) From c8b9486fd3ac6a1692965013d1685e66cb197bfe Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:51 -0500 Subject: [PATCH 020/129] spelling: nonexistent --- context/remote_test.go | 4 ++-- git/ssh_config_test.go | 2 +- internal/config/config_file_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/context/remote_test.go b/context/remote_test.go index ab3f7e2e2..1d2140c90 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -28,11 +28,11 @@ func Test_Remotes_FindByName(t *testing.T) { eq(t, err, nil) eq(t, r.Name, "upstream") - r, err = list.FindByName("nonexist", "*") + r, err = list.FindByName("nonexistent", "*") eq(t, err, nil) eq(t, r.Name, "mona") - _, err = list.FindByName("nonexist") + _, err = list.FindByName("nonexistent") eq(t, err, errors.New(`no GitHub remotes found`)) } diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go index 28f339aa6..7aafc5b21 100644 --- a/git/ssh_config_test.go +++ b/git/ssh_config_test.go @@ -25,7 +25,7 @@ func Test_sshParse(t *testing.T) { `)) eq(t, m["foo"], "example.com") eq(t, m["bar"], "%bar.net%") - eq(t, m["nonexist"], "") + eq(t, m["nonexistent"], "") } func Test_Translator(t *testing.T) { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index f40cb9097..cc6ba287f 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -90,7 +90,7 @@ example.com: val, err = config.Get("github.com", "git_protocol") eq(t, err, nil) eq(t, val, "ssh") - val, err = config.Get("nonexist.io", "git_protocol") + val, err = config.Get("nonexistent.io", "git_protocol") eq(t, err, nil) eq(t, val, "ssh") } From e5f59a15fe6de03de0c17b8ec1ed004bf8c5afc6 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:51 -0500 Subject: [PATCH 021/129] spelling: response --- api/cache.go | 6 +++--- api/cache_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/cache.go b/api/cache.go index 620660c15..1f6d8896b 100644 --- a/api/cache.go +++ b/api/cache.go @@ -19,7 +19,7 @@ import ( func makeCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client { cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache") return &http.Client{ - Transport: CacheReponse(cacheTTL, cacheDir)(httpClient.Transport), + Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport), } } @@ -39,8 +39,8 @@ func isCacheableResponse(res *http.Response) bool { return res.StatusCode < 500 && res.StatusCode != 403 } -// CacheReponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time -func CacheReponse(ttl time.Duration, dir string) ClientOption { +// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time +func CacheResponse(ttl time.Duration, dir string) ClientOption { fs := fileStorage{ dir: dir, ttl: ttl, diff --git a/api/cache_test.go b/api/cache_test.go index d1039d71b..f4a6a756e 100644 --- a/api/cache_test.go +++ b/api/cache_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_CacheReponse(t *testing.T) { +func Test_CacheResponse(t *testing.T) { counter := 0 fakeHTTP := funcTripper{ roundTrip: func(req *http.Request) (*http.Response, error) { @@ -32,7 +32,7 @@ func Test_CacheReponse(t *testing.T) { } cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") - httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheReponse(time.Minute, cacheDir)) + httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir)) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) From ec82d3c47e5b2340df8ccbda074ec2120b0c25fe Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:51 -0500 Subject: [PATCH 022/129] spelling: settings --- pkg/cmd/repo/garden/garden.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go index 359d8d898..c7ee048a7 100644 --- a/pkg/cmd/repo/garden/garden.go +++ b/pkg/cmd/repo/garden/garden.go @@ -190,7 +190,7 @@ func gardenRun(opts *GardenOptions) error { oldTTYCommand := exec.Command("stty", sttyFileArg, "/dev/tty", "-g") oldTTYSettings, err := oldTTYCommand.CombinedOutput() if err != nil { - fmt.Fprintln(out, "getting TTY setings failed:", string(oldTTYSettings)) + fmt.Fprintln(out, "getting TTY settings failed:", string(oldTTYSettings)) return err } From ded92972cd684f95f2e35ad3ee20f3f28a71bd86 Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:51 -0500 Subject: [PATCH 023/129] spelling: template --- pkg/cmd/issue/create/create_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index c89a349a0..0ced19b62 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -145,7 +145,7 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { as, teardown := prompt.InitAskStubber() defer teardown() - // tmeplate + // template as.Stub([]*prompt.QuestionStub{ { Name: "index", From a66a65d4220b970af61b27fbe7ce9b4b62c0c7ba Mon Sep 17 00:00:00 2001 From: Josh Soref Date: Sat, 21 Nov 2020 21:18:52 -0500 Subject: [PATCH 024/129] spelling: unmatched --- internal/run/stub.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/run/stub.go b/internal/run/stub.go index 9bd6e279b..f11834c19 100644 --- a/internal/run/stub.go +++ b/internal/run/stub.go @@ -39,7 +39,7 @@ func Stub() (*CommandStubber, func(T)) { return } t.Helper() - t.Errorf("umatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) + t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) } } From d56d92c908cb2454d9b592916e980754fa310041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 23 Nov 2020 20:19:18 +0100 Subject: [PATCH 025/129] If git credential helper is non-defined, set gh as credential helper --- internal/config/config_type.go | 3 +- pkg/cmd/auth/auth.go | 2 + pkg/cmd/auth/gitcredential/helper.go | 112 +++++++++++++++++++++++++++ pkg/cmd/auth/login/login.go | 102 ++++++++++++++++++------ 4 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 pkg/cmd/auth/gitcredential/helper.go diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 40533f211..4c04d9f6b 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strings" "github.com/cli/cli/internal/ghinstance" "gopkg.in/yaml.v3" @@ -378,7 +379,7 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) { } for _, hc := range hosts { - if hc.Host == hostname { + if strings.EqualFold(hc.Host, hostname) { return hc, nil } } diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index ddeb07aa6..6909f99f8 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + gitCredentialCmd "github.com/cli/cli/pkg/cmd/auth/gitcredential" authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login" authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout" authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh" @@ -22,6 +23,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil)) cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) + cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) return cmd } diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go new file mode 100644 index 000000000..a9dbcbad9 --- /dev/null +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -0,0 +1,112 @@ +package login + +import ( + "bufio" + "fmt" + "net/url" + "strings" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type CredentialOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Operation string +} + +func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command { + opts := &CredentialOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "git-credential", + Args: cobra.ExactArgs(1), + Short: "Implements git credential helper protocol", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Operation = args[0] + + if runF != nil { + return runF(opts) + } + + return helperRun(opts) + }, + } + + return cmd +} + +func helperRun(opts *CredentialOptions) error { + if opts.Operation == "store" { + // We pretend to implement the "store" operation, but do nothing since we already have a cached token. + return cmdutil.SilentError + } + + if opts.Operation != "get" { + return fmt.Errorf("gh auth git-credential: %q operation not supported", opts.Operation) + } + + wants := map[string]string{} + + s := bufio.NewScanner(opts.IO.In) + for s.Scan() { + line := s.Text() + if line == "" { + break + } + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + wants[parts[0]] = parts[1] + } + if err := s.Err(); err != nil { + return err + } + + if uv := wants["url"]; uv != "" { + u, err := url.Parse(uv) + if err != nil { + return err + } + wants["protocol"] = u.Scheme + wants["host"] = u.Host + wants["path"] = u.Path + wants["username"] = u.User.Username() + wants["password"], _ = u.User.Password() + } + + if wants["protocol"] != "https" { + return cmdutil.SilentError + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + gotUser, _ := cfg.Get(wants["host"], "user") + gotToken, _ := cfg.Get(wants["host"], "oauth_token") + if gotUser == "" || gotToken == "" { + return cmdutil.SilentError + } + + if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) { + return cmdutil.SilentError + } + + fmt.Fprint(opts.IO.Out, "protocol=https\n") + fmt.Fprintf(opts.IO.Out, "host=%s\n", wants["host"]) + fmt.Fprintf(opts.IO.Out, "username=%s\n", gotUser) + fmt.Fprintf(opts.IO.Out, "password=%s\n", gotToken) + + return nil +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 4246d5d8b..f92e6e7f3 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/ioutil" + "path/filepath" "strings" "github.com/AlecAivazis/survey/v2" @@ -19,6 +20,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -314,32 +316,62 @@ func loginRun(opts *LoginOptions) error { } if opts.Interactive && gitProtocol == "https" { - var primeCredentials bool - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Set up git for passwordless push/pull operations?", - Default: true, - }, &primeCredentials) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - if primeCredentials { - gitCredential, err := git.GitCommand("credential", "approve") + helper, _ := gitCredentialHelper(hostname) + if !isOurCredentialHelper(helper) { + var primeCredentials bool + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Set up git for passwordless push/pull operations?", + Default: true, + }, &primeCredentials) if err != nil { - return err + return fmt.Errorf("could not prompt: %w", err) } - credentialStdin := &bytes.Buffer{} - gitCredential.Stdin = credentialStdin - password, _ := cfg.Get(hostname, "oauth_token") - fmt.Fprint(credentialStdin, "protocol=https\n") - fmt.Fprintf(credentialStdin, "host=%s\n", hostname) - fmt.Fprintf(credentialStdin, "username=%s\n", username) - fmt.Fprintf(credentialStdin, "password=%s\n", password) - fmt.Fprint(credentialStdin, "\n") + if primeCredentials { + if helper == "" { + configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential") + if err != nil { + return err + } - err = run.PrepareCmd(gitCredential).Run() - if err != nil { - return err + err = run.PrepareCmd(configureCmd).Run() + if err != nil { + return err + } + } else { + rejectCmd, err := git.GitCommand("credential", "reject") + if err != nil { + return err + } + + rejectCmd.Stdin = bytes.NewBufferString(fmt.Sprintf(heredoc.Doc(` + protocol=https + host=%s + `), hostname)) + + err = run.PrepareCmd(rejectCmd).Run() + if err != nil { + return err + } + + approveCmd, err := git.GitCommand("credential", "approve") + if err != nil { + return err + } + + password, _ := cfg.Get(hostname, "oauth_token") + approveCmd.Stdin = bytes.NewBufferString(fmt.Sprintf(heredoc.Doc(` + protocol=https + host=%s + username=%s + password=%s + `), hostname, username, password)) + + err = run.PrepareCmd(approveCmd).Run() + if err != nil { + return err + } + } } } } @@ -358,3 +390,29 @@ func getAccessTokenTip(hostname string) string { Tip: you can generate a Personal Access Token here https://%s/settings/tokens The minimum required scopes are 'repo' and 'read:org'.`, ghHostname) } + +func gitCredentialHelperKey(hostname string) string { + return fmt.Sprintf("credential.https://%s.helper", hostname) +} + +func gitCredentialHelper(hostname string) (helper string, err error) { + helper, err = git.Config(gitCredentialHelperKey(hostname)) + if helper != "" { + return + } + helper, err = git.Config("credential.helper") + return +} + +func isOurCredentialHelper(cmd string) bool { + if !strings.HasPrefix(cmd, "!") { + return false + } + + args, err := shlex.Split(cmd[1:]) + if err != nil || len(args) == 0 { + return false + } + + return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh" +} From e92cd432598775998211267eed0c7ef79f825e88 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 3 Nov 2020 13:08:37 -0800 Subject: [PATCH 026/129] add IOStreams.ReadUserFile --- pkg/cmd/api/api.go | 17 +---------------- pkg/iostreams/iostreams.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 16e61e7e1..63437937c 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -403,7 +403,7 @@ func parseField(f string) (string, string, error) { func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { if strings.HasPrefix(v, "@") { - return readUserFile(v[1:], opts.IO.In) + return opts.IO.ReadUserFile(v[1:]) } if n, err := strconv.Atoi(v); err == nil { @@ -422,21 +422,6 @@ func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { } } -func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) { - var r io.ReadCloser - if fn == "-" { - r = stdin - } else { - var err error - r, err = os.Open(fn) - if err != nil { - return nil, err - } - } - defer r.Close() - return ioutil.ReadAll(r) -} - func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) { if fn == "-" { return stdin, -1, nil diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 99ae0cfdc..a90222fdd 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -253,6 +253,21 @@ func (s *IOStreams) ColorScheme() *ColorScheme { return NewColorScheme(s.ColorEnabled(), s.ColorSupport256()) } +func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { + var r io.ReadCloser + if fn == "-" { + r = s.In + } else { + var err error + r, err = os.Open(fn) + if err != nil { + return nil, err + } + } + defer r.Close() + return ioutil.ReadAll(r) +} + func System() *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) From d300526318bd7589ed1527a7a9b376336e8c4e32 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 13 Nov 2020 10:11:39 -0800 Subject: [PATCH 027/129] preserve and restore issue/pr input on failure --- pkg/cmd/issue/create/create.go | 72 ++++++++++++------ pkg/cmd/issue/create/create_test.go | 40 ++++++++++ pkg/cmd/pr/create/create.go | 57 ++++++++++---- pkg/cmd/pr/create/create_test.go | 48 ++++++++++++ pkg/cmd/pr/shared/preserve.go | 66 ++++++++++++++++ pkg/cmd/pr/shared/preserve_test.go | 114 ++++++++++++++++++++++++++++ pkg/cmd/pr/shared/state.go | 73 ++++++++++++++++++ pkg/cmd/pr/shared/survey.go | 44 +++-------- pkg/iostreams/color.go | 4 + pkg/iostreams/iostreams.go | 10 +++ 10 files changed, 457 insertions(+), 71 deletions(-) create mode 100644 pkg/cmd/pr/shared/preserve.go create mode 100644 pkg/cmd/pr/shared/preserve_test.go create mode 100644 pkg/cmd/pr/shared/state.go diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 28c8f0148..6681af62a 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -26,6 +26,8 @@ type CreateOptions struct { RepoOverride string WebMode bool + JSONFill bool + JSONInput string Title string Body string @@ -62,6 +64,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co titleProvided := cmd.Flags().Changed("title") bodyProvided := cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") + opts.JSONFill = cmd.Flags().Changed("json") opts.Interactive = !(titleProvided && bodyProvided) @@ -69,6 +72,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return &cmdutil.FlagError{Err: errors.New("must provide --title and --body when not running interactively")} } + if opts.JSONFill { + opts.Interactive = false + + if opts.WebMode { + return errors.New("--web and --json are mutually exclusive") + } + } + if runF != nil { return runF(opts) } @@ -83,20 +94,21 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") + cmd.Flags().StringVarP(&opts.JSONInput, "json", "j", "", "Use JSON to populate and submit issue") return cmd } -func createRun(opts *CreateOptions) error { +func createRun(opts *CreateOptions) (err error) { httpClient, err := opts.HttpClient() if err != nil { - return err + return } apiClient := api.NewClientFromHTTP(httpClient) baseRepo, err := opts.BaseRepo() if err != nil { - return err + return } templateFiles, legacyTemplate := prShared.FindTemplates(opts.RootDirOverride, "ISSUE_TEMPLATE") @@ -123,7 +135,7 @@ func createRun(opts *CreateOptions) error { if opts.Title != "" || opts.Body != "" { openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb) if err != nil { - return err + return } } else if len(templateFiles) > 1 { openURL += "/choose" @@ -140,24 +152,28 @@ func createRun(opts *CreateOptions) error { repo, err := api.GitHubRepo(apiClient, baseRepo) if err != nil { - return err + return } if !repo.HasIssuesEnabled { - return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + err = fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + return } action := prShared.SubmitAction if opts.Interactive { - editorCommand, err := cmdutil.DetermineEditor(opts.Config) + var editorCommand string + editorCommand, err = cmdutil.DetermineEditor(opts.Config) if err != nil { - return err + return } + defer prShared.PreserveInput(opts.IO, &tb, &err)() + if tb.Title == "" { err = prShared.TitleSurvey(&tb) if err != nil { - return err + return } } @@ -166,12 +182,12 @@ func createRun(opts *CreateOptions) error { templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb) if err != nil { - return err + return } err = prShared.BodySurvey(&tb, templateContent, editorCommand) if err != nil { - return err + return } if tb.Body == "" { @@ -179,31 +195,40 @@ func createRun(opts *CreateOptions) error { } } - action, err := prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage()) + var action prShared.Action + action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage()) if err != nil { - return fmt.Errorf("unable to confirm: %w", err) + err = fmt.Errorf("unable to confirm: %w", err) + return } if action == prShared.MetadataAction { err = prShared.MetadataSurvey(opts.IO, apiClient, baseRepo, &tb) if err != nil { - return err + return } action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), false) if err != nil { - return err + return } } if action == prShared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") - - return nil + return } + } else if opts.JSONFill { + err = prShared.FillFromJSON(opts.IO, opts.JSONInput, &tb) + if err != nil { + return + } + + action = prShared.SubmitAction } else { if tb.Title == "" { - return fmt.Errorf("title can't be blank") + err = fmt.Errorf("title can't be blank") + return } } @@ -211,7 +236,7 @@ func createRun(opts *CreateOptions) error { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb) if err != nil { - return err + return } if isTerminal { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) @@ -225,12 +250,13 @@ func createRun(opts *CreateOptions) error { err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) if err != nil { - return err + return } - newIssue, err := api.IssueCreate(apiClient, repo, params) + var newIssue *api.Issue + newIssue, err = api.IssueCreate(apiClient, repo, params) if err != nil { - return err + return } fmt.Fprintln(opts.IO.Out, newIssue.URL) @@ -238,5 +264,5 @@ func createRun(opts *CreateOptions) error { panic("Unreachable state") } - return nil + return } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 0ced19b62..cd05cf6c4 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -126,6 +126,46 @@ func TestIssueCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } +func TestIssueCreate_JSON(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `)) + + output, err := runCommand(http, true, `-j'{"title":"cool", "body":"issue"}'`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "cool") + eq(t, reqBody.Variables.Input.Body, "issue") + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + func TestIssueCreate_nonLegacyTemplate(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 9e0180965..39d97dcd6 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -38,8 +38,10 @@ type CreateOptions struct { RootDirOverride string RepoOverride string - Autofill bool - WebMode bool + Autofill bool + WebMode bool + JSONFill bool + JSONInput string IsDraft bool Title string @@ -99,11 +101,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { + opts.JSONFill = cmd.Flags().Changed("json") opts.TitleProvided = cmd.Flags().Changed("title") opts.BodyProvided = cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") - if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { + if !opts.IO.CanPrompt() && !opts.JSONFill && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")} } @@ -114,6 +117,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return errors.New("the --reviewer flag is not supported with --web") } + if opts.JSONFill && opts.WebMode { + return errors.New("--web and --json are mutually exclusive") + } + if runF != nil { return runF(opts) } @@ -134,6 +141,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") + fl.StringVarP(&opts.JSONInput, "json", "j", "", "Use JSON to populate and submit PR") return cmd } @@ -141,14 +149,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) (err error) { ctx, err := NewCreateContext(opts) if err != nil { - return err + return } client := ctx.Client state, err := NewIssueState(*ctx, *opts) if err != nil { - return err + return } if opts.WebMode { @@ -156,9 +164,9 @@ func createRun(opts *CreateOptions) (err error) { state.Title = opts.Title state.Body = opts.Body } - err := handlePush(*opts, *ctx) + err = handlePush(*opts, *ctx) if err != nil { - return err + return } return previewPR(*opts, *ctx, *state) } @@ -199,35 +207,51 @@ func createRun(opts *CreateOptions) (err error) { if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) { err = handlePush(*opts, *ctx) if err != nil { - return err + return } return submitPR(*opts, *ctx, *state) } + if opts.JSONFill { + err = shared.FillFromJSON(opts.IO, opts.JSONInput, state) + if err != nil { + return fmt.Errorf("could not use JSON input: %w", err) + } + + err = handlePush(*opts, *ctx) + if err != nil { + return + } + + return submitPR(*opts, *ctx, *state) + } + if !opts.TitleProvided { err = shared.TitleSurvey(state) if err != nil { - return err + return } } editorCommand, err := cmdutil.DetermineEditor(opts.Config) if err != nil { - return err + return } + defer shared.PreserveInput(opts.IO, state, &err)() + templateContent := "" if !opts.BodyProvided { templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state) if err != nil { - return err + return } err = shared.BodySurvey(state, templateContent, editorCommand) if err != nil { - return err + return } if state.Body == "" { @@ -244,12 +268,12 @@ func createRun(opts *CreateOptions) (err error) { if action == shared.MetadataAction { err = shared.MetadataSurvey(opts.IO, client, ctx.BaseRepo, state) if err != nil { - return err + return } action, err = shared.ConfirmSubmission(!state.HasMetadata(), false) if err != nil { - return err + return } } @@ -260,7 +284,7 @@ func createRun(opts *CreateOptions) (err error) { err = handlePush(*opts, *ctx) if err != nil { - return err + return } if action == shared.PreviewAction { @@ -271,7 +295,8 @@ func createRun(opts *CreateOptions) (err error) { return submitPR(*opts, *ctx, *state) } - return errors.New("expected to cancel, preview, or submit") + err = errors.New("expected to cancel, preview, or submit") + return } func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 0c9a91c1a..3228d51f8 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -136,6 +136,54 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { assert.Equal(t, "", output.String()) } +func TestPRCreate_json(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "master") + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + + output, err := runCommand(http, nil, "feature", false, `-j'{"title":"cool", "body":"pr"}' -H feature`) + require.NoError(t, err) + + bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + BaseRefName string + HeadRefName string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID) + assert.Equal(t, "cool", reqBody.Variables.Input.Title) + assert.Equal(t, "pr", reqBody.Variables.Input.Body) + assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName) + assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName) + + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) +} + func TestPRCreate_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go new file mode 100644 index 000000000..ba8c8cc46 --- /dev/null +++ b/pkg/cmd/pr/shared/preserve.go @@ -0,0 +1,66 @@ +package shared + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/cli/cli/pkg/iostreams" +) + +func dumpPath(random int64) string { + r := fmt.Sprintf("%x", random) + r = r[len(r)-5:] + dumpFilename := fmt.Sprintf("gh%s.json", r) + return filepath.Join(os.TempDir(), dumpFilename) +} + +func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr *error) func() { + return func() { + if !state.IsDirty() { + return + } + + if *createErr == nil { + return + } + + out := io.ErrOut + + // this extra newline guards against appending to the end of a survey line + fmt.Fprintln(out) + + data, err := json.Marshal(state) + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintf(out, "%v\n", state) + return + } + + dp := dumpPath(time.Now().UnixNano()) + + err = io.WriteFile(dp, data) + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintln(out, string(data)) + return + } + + cs := io.ColorScheme() + + issueType := "pr" + if state.Type == IssueMetadata { + issueType = "issue" + } + + fmt.Fprintf(out, "%s operation failed. input saved to: %s\n", cs.FailureIcon(), dp) + fmt.Fprintf(out, "resubmit with: gh %s create -j@%s\n", issueType, dp) + + // some whitespace before the actual error + fmt.Fprintln(out) + } +} diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go new file mode 100644 index 000000000..706544023 --- /dev/null +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -0,0 +1,114 @@ +package shared + +import ( + "encoding/json" + "errors" + "os" + "testing" + + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/stretchr/testify/assert" +) + +func Test_dumpPath(t *testing.T) { + // mostly pointless test + var random int64 = 1234567890 + tempDir := os.TempDir() + assert.Equal(t, dumpPath(random), tempDir+"/gh602d2.json") +} + +func Test_PreserveInput(t *testing.T) { + tests := []struct { + name string + state *IssueMetadataState + err bool + wantErrLines []string + wantPreservation bool + }{ + { + name: "err, no changes to state", + err: true, + }, + { + name: "no err, no changes to state", + err: false, + }, + { + name: "no err, changes to state", + state: &IssueMetadataState{ + dirty: true, + }, + }, + { + name: "err, title/body input received", + state: &IssueMetadataState{ + dirty: true, + Title: "almost a", + Body: "jill sandwich", + Reviewers: []string{"barry", "chris"}, + Labels: []string{"sandwich"}, + }, + wantErrLines: []string{ + `X operation failed. input saved to:.*\.json`, + `resubmit with: gh issue create -j@.*\.json`, + }, + err: true, + wantPreservation: true, + }, + { + name: "err, metadata received", + state: &IssueMetadataState{ + Reviewers: []string{"barry", "chris"}, + Labels: []string{"sandwich"}, + }, + wantErrLines: []string{ + `X operation failed. input saved to:.*\.json`, + `resubmit with: gh issue create -j@.*\.json`, + }, + err: true, + wantPreservation: true, + }, + { + name: "err, dirty, pull request", + state: &IssueMetadataState{ + dirty: true, + Title: "a pull request", + Type: PRMetadata, + }, + wantErrLines: []string{ + `X operation failed. input saved to:.*\.json`, + `resubmit with: gh pr create -j@.*\.json`, + }, + err: true, + wantPreservation: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.state == nil { + tt.state = &IssueMetadataState{} + } + io, _, _, errOut := iostreams.Test() + io.WriteOverride = []byte{} + var err error + if tt.err { + err = errors.New("error during creation") + } + + PreserveInput(io, tt.state, &err)() + + if tt.wantPreservation { + test.ExpectLines(t, errOut.String(), tt.wantErrLines...) + preserved := &IssueMetadataState{} + assert.NoError(t, json.Unmarshal(io.WriteOverride, preserved)) + preserved.dirty = tt.state.dirty + assert.Equal(t, preserved, tt.state) + } else { + assert.Equal(t, errOut.String(), "") + assert.Equal(t, string(io.WriteOverride), "") + } + }) + } +} diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go new file mode 100644 index 000000000..6c8e3531e --- /dev/null +++ b/pkg/cmd/pr/shared/state.go @@ -0,0 +1,73 @@ +package shared + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/iostreams" +) + +type metadataStateType int + +const ( + IssueMetadata metadataStateType = iota + PRMetadata +) + +type IssueMetadataState struct { + Type metadataStateType + + Draft bool + + Body string + Title string + + Metadata []string + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestones []string + + MetadataResult *api.RepoMetadataResult + + dirty bool // whether user i/o has modified this +} + +func (tb *IssueMetadataState) MarkDirty() { + tb.dirty = true +} + +func (tb *IssueMetadataState) IsDirty() bool { + return tb.dirty || tb.HasMetadata() +} + +func (tb *IssueMetadataState) HasMetadata() bool { + return len(tb.Reviewers) > 0 || + len(tb.Assignees) > 0 || + len(tb.Labels) > 0 || + len(tb.Projects) > 0 || + len(tb.Milestones) > 0 +} + +func FillFromJSON(io *iostreams.IOStreams, JSONInput string, state *IssueMetadataState) error { + var data []byte + var err error + if strings.HasPrefix(JSONInput, "@") { + data, err = io.ReadUserFile(JSONInput[1:]) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", JSONInput[1:], err) + } + } else { + data = []byte(JSONInput) + } + + err = json.Unmarshal(data, state) + if err != nil { + return fmt.Errorf("JSON parsing failure: %w", err) + } + + return nil +} diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 03bddb1f8..4e4a72855 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -16,38 +16,6 @@ import ( ) type Action int -type metadataStateType int - -const ( - IssueMetadata metadataStateType = iota - PRMetadata -) - -type IssueMetadataState struct { - Type metadataStateType - - Draft bool - - Body string - Title string - - Metadata []string - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestones []string - - MetadataResult *api.RepoMetadataResult -} - -func (tb *IssueMetadataState) HasMetadata() bool { - return len(tb.Reviewers) > 0 || - len(tb.Assignees) > 0 || - len(tb.Labels) > 0 || - len(tb.Projects) > 0 || - len(tb.Milestones) > 0 -} const ( SubmitAction Action = iota @@ -170,6 +138,8 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string state.Body += templateContent } + preBody := state.Body + // TODO should just be an AskOne but ran into problems with the stubber qs := []*survey.Question{ { @@ -193,10 +163,16 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string return err } + if state.Body != "" && preBody != state.Body { + state.MarkDirty() + } + return nil } func TitleSurvey(state *IssueMetadataState) error { + preTitle := state.Title + // TODO should just be an AskOne but ran into problems with the stubber qs := []*survey.Question{ { @@ -213,6 +189,10 @@ func TitleSurvey(state *IssueMetadataState) error { return err } + if preTitle != state.Title { + state.MarkDirty() + } + return nil } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 7e429e407..6fc2bd023 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -122,6 +122,10 @@ func (c *ColorScheme) WarningIcon() string { return c.Yellow("!") } +func (c *ColorScheme) FailureIcon() string { + return c.Red("X") +} + func (c *ColorScheme) ColorFromString(s string) func(string) string { s = strings.ToLower(s) var fn func(string) string diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index a90222fdd..87615b614 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -45,6 +45,8 @@ type IOStreams struct { pagerProcess *os.Process neverPrompt bool + + WriteOverride []byte } func (s *IOStreams) ColorEnabled() bool { @@ -268,6 +270,14 @@ func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { return ioutil.ReadAll(r) } +func (s *IOStreams) WriteFile(fn string, data []byte) error { + if s.WriteOverride != nil { + s.WriteOverride = data + return nil + } + return ioutil.WriteFile(fn, data, 0660) +} + func System() *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) From fffd315a7e3c977825ef921af8f7ceb997f9f1d3 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 16 Nov 2020 14:08:14 -0800 Subject: [PATCH 028/129] fix dumb test --- pkg/cmd/pr/shared/preserve_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index 706544023..b69154113 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "os" + "path/filepath" "testing" "github.com/cli/cli/pkg/iostreams" @@ -14,8 +15,7 @@ import ( func Test_dumpPath(t *testing.T) { // mostly pointless test var random int64 = 1234567890 - tempDir := os.TempDir() - assert.Equal(t, dumpPath(random), tempDir+"/gh602d2.json") + assert.Equal(t, dumpPath(random), filepath.Join(os.TempDir(), "gh602d2.json")) } func Test_PreserveInput(t *testing.T) { From f68909b7a840d08a542d86939c799d4e45fcf35a Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 20 Nov 2020 10:57:35 -0800 Subject: [PATCH 029/129] use TempFile though the testing is gross --- pkg/cmd/pr/shared/preserve.go | 17 +++++++---- pkg/cmd/pr/shared/preserve_test.go | 47 ++++++++++++++++++++---------- pkg/iostreams/iostreams.go | 12 ++++---- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go index ba8c8cc46..6d0b7303c 100644 --- a/pkg/cmd/pr/shared/preserve.go +++ b/pkg/cmd/pr/shared/preserve.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "time" "github.com/cli/cli/pkg/iostreams" ) @@ -40,9 +39,17 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr return } - dp := dumpPath(time.Now().UnixNano()) + tmpfile, err := io.TempFile(os.TempDir(), "gh*.json") + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintf(out, "%v\n", state) + return + } - err = io.WriteFile(dp, data) + tmpfilePath := filepath.Join(os.TempDir(), tmpfile.Name()) + + _, err = tmpfile.Write(data) if err != nil { fmt.Fprintf(out, "failed to save input to file: %s\n", err) fmt.Fprintln(out, "would have saved:") @@ -57,8 +64,8 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr issueType = "issue" } - fmt.Fprintf(out, "%s operation failed. input saved to: %s\n", cs.FailureIcon(), dp) - fmt.Fprintf(out, "resubmit with: gh %s create -j@%s\n", issueType, dp) + fmt.Fprintf(out, "%s operation failed. input saved to: %s\n", cs.FailureIcon(), tmpfilePath) + fmt.Fprintf(out, "resubmit with: gh %s create -j@%s\n", issueType, tmpfilePath) // some whitespace before the actual error fmt.Fprintln(out) diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index b69154113..8dc574676 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -3,6 +3,7 @@ package shared import ( "encoding/json" "errors" + "io/ioutil" "os" "path/filepath" "testing" @@ -12,12 +13,6 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_dumpPath(t *testing.T) { - // mostly pointless test - var random int64 = 1234567890 - assert.Equal(t, dumpPath(random), filepath.Join(os.TempDir(), "gh602d2.json")) -} - func Test_PreserveInput(t *testing.T) { tests := []struct { name string @@ -50,8 +45,8 @@ func Test_PreserveInput(t *testing.T) { Labels: []string{"sandwich"}, }, wantErrLines: []string{ - `X operation failed. input saved to:.*\.json`, - `resubmit with: gh issue create -j@.*\.json`, + `X operation failed. input saved to:.*testfile.*`, + `resubmit with: gh issue create -j@.*testfile.*`, }, err: true, wantPreservation: true, @@ -63,8 +58,8 @@ func Test_PreserveInput(t *testing.T) { Labels: []string{"sandwich"}, }, wantErrLines: []string{ - `X operation failed. input saved to:.*\.json`, - `resubmit with: gh issue create -j@.*\.json`, + `X operation failed. input saved to:.*testfile.*`, + `resubmit with: gh issue create -j@.*testfile.*`, }, err: true, wantPreservation: true, @@ -77,8 +72,8 @@ func Test_PreserveInput(t *testing.T) { Type: PRMetadata, }, wantErrLines: []string{ - `X operation failed. input saved to:.*\.json`, - `resubmit with: gh pr create -j@.*\.json`, + `X operation failed. input saved to:.*testfile.*`, + `resubmit with: gh pr create -j@.*testfile.*`, }, err: true, wantPreservation: true, @@ -90,8 +85,15 @@ func Test_PreserveInput(t *testing.T) { if tt.state == nil { tt.state = &IssueMetadataState{} } + io, _, _, errOut := iostreams.Test() - io.WriteOverride = []byte{} + + tfPath, tf, tferr := tmpfile() + assert.NoError(t, tferr) + defer os.Remove(tfPath) + + io.TempFileOverride = tf + var err error if tt.err { err = errors.New("error during creation") @@ -99,16 +101,31 @@ func Test_PreserveInput(t *testing.T) { PreserveInput(io, tt.state, &err)() + tf.Seek(0, 0) + + data, err := ioutil.ReadAll(tf) + assert.NoError(t, err) + if tt.wantPreservation { test.ExpectLines(t, errOut.String(), tt.wantErrLines...) preserved := &IssueMetadataState{} - assert.NoError(t, json.Unmarshal(io.WriteOverride, preserved)) + assert.NoError(t, json.Unmarshal(data, preserved)) preserved.dirty = tt.state.dirty assert.Equal(t, preserved, tt.state) } else { assert.Equal(t, errOut.String(), "") - assert.Equal(t, string(io.WriteOverride), "") + assert.Equal(t, string(data), "") } }) } } + +func tmpfile() (string, *os.File, error) { + dir := os.TempDir() + tmpfile, err := ioutil.TempFile(dir, "testfile*") + if err != nil { + return "", nil, err + } + + return filepath.Join(dir, tmpfile.Name()), tmpfile, nil +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 87615b614..ae200e3d1 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -46,7 +46,7 @@ type IOStreams struct { neverPrompt bool - WriteOverride []byte + TempFileOverride *os.File } func (s *IOStreams) ColorEnabled() bool { @@ -270,12 +270,12 @@ func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { return ioutil.ReadAll(r) } -func (s *IOStreams) WriteFile(fn string, data []byte) error { - if s.WriteOverride != nil { - s.WriteOverride = data - return nil +func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) { + if s.TempFileOverride != nil { + fmt.Printf("DBG %#v\n", s.TempFileOverride) + return s.TempFileOverride, nil } - return ioutil.WriteFile(fn, data, 0660) + return ioutil.TempFile(dir, pattern) } func System() *IOStreams { From 1d408eb30de84538cea441dcb45ffd9c9cf416c3 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 20 Nov 2020 11:00:25 -0800 Subject: [PATCH 030/129] linter appeasement --- pkg/cmd/pr/shared/preserve.go | 7 ------- pkg/cmd/pr/shared/preserve_test.go | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go index 6d0b7303c..8002e4a59 100644 --- a/pkg/cmd/pr/shared/preserve.go +++ b/pkg/cmd/pr/shared/preserve.go @@ -9,13 +9,6 @@ import ( "github.com/cli/cli/pkg/iostreams" ) -func dumpPath(random int64) string { - r := fmt.Sprintf("%x", random) - r = r[len(r)-5:] - dumpFilename := fmt.Sprintf("gh%s.json", r) - return filepath.Join(os.TempDir(), dumpFilename) -} - func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr *error) func() { return func() { if !state.IsDirty() { diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index 8dc574676..a4211e55f 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -101,7 +101,8 @@ func Test_PreserveInput(t *testing.T) { PreserveInput(io, tt.state, &err)() - tf.Seek(0, 0) + _, err = tf.Seek(0, 0) + assert.NoError(t, err) data, err := ioutil.ReadAll(tf) assert.NoError(t, err) From d6e84a75fb3009d50d3a154251a3209097315c04 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 20 Nov 2020 11:43:41 -0800 Subject: [PATCH 031/129] switch to recover instead of resubmit --- pkg/cmd/issue/create/create.go | 45 +++++++------ pkg/cmd/issue/create/create_test.go | 83 ++++++++++++++++++------ pkg/cmd/pr/create/create.go | 43 ++++++------- pkg/cmd/pr/create/create_test.go | 98 +++++++++++++++++++++-------- pkg/cmd/pr/shared/preserve.go | 6 +- pkg/cmd/pr/shared/preserve_test.go | 30 +++------ pkg/cmd/pr/shared/state.go | 13 ++-- pkg/iostreams/iostreams.go | 1 - 8 files changed, 188 insertions(+), 131 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 6681af62a..a2912f638 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -26,8 +26,7 @@ type CreateOptions struct { RepoOverride string WebMode bool - JSONFill bool - JSONInput string + RecoverFile string Title string Body string @@ -64,7 +63,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co titleProvided := cmd.Flags().Changed("title") bodyProvided := cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") - opts.JSONFill = cmd.Flags().Changed("json") + + if !opts.IO.CanPrompt() && opts.RecoverFile != "" { + return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")} + } opts.Interactive = !(titleProvided && bodyProvided) @@ -72,14 +74,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return &cmdutil.FlagError{Err: errors.New("must provide --title and --body when not running interactively")} } - if opts.JSONFill { - opts.Interactive = false - - if opts.WebMode { - return errors.New("--web and --json are mutually exclusive") - } - } - if runF != nil { return runF(opts) } @@ -94,7 +88,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") - cmd.Flags().StringVarP(&opts.JSONInput, "json", "j", "", "Use JSON to populate and submit issue") + cmd.Flags().StringVarP(&opts.RecoverFile, "recover", "e", "", "Recover input from a failed run of create") return cmd } @@ -130,6 +124,14 @@ func createRun(opts *CreateOptions) (err error) { Body: opts.Body, } + if opts.RecoverFile != "" { + err = prShared.FillFromJSON(opts.IO, opts.RecoverFile, &tb) + if err != nil { + err = fmt.Errorf("failed to recover input: %w", err) + return + } + } + if opts.WebMode { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") if opts.Title != "" || opts.Body != "" { @@ -170,19 +172,21 @@ func createRun(opts *CreateOptions) (err error) { defer prShared.PreserveInput(opts.IO, &tb, &err)() - if tb.Title == "" { + if opts.Title == "" { err = prShared.TitleSurvey(&tb) if err != nil { return } } - if tb.Body == "" { + if opts.Body == "" { templateContent := "" - templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb) - if err != nil { - return + if opts.RecoverFile == "" { + templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb) + if err != nil { + return + } } err = prShared.BodySurvey(&tb, templateContent, editorCommand) @@ -218,13 +222,6 @@ func createRun(opts *CreateOptions) (err error) { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") return } - } else if opts.JSONFill { - err = prShared.FillFromJSON(opts.IO, opts.JSONInput, &tb) - if err != nil { - return - } - - action = prShared.SubmitAction } else { if tb.Title == "" { err = fmt.Errorf("title can't be blank") diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index cd05cf6c4..733094964 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -3,8 +3,10 @@ package create import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" + "os" "os/exec" "reflect" "strings" @@ -13,6 +15,7 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + prShared "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" @@ -126,7 +129,7 @@ func TestIssueCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } -func TestIssueCreate_JSON(t *testing.T) { +func TestIssueCreate_recover(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) @@ -136,33 +139,73 @@ func TestIssueCreate_JSON(t *testing.T) { "hasIssuesEnabled": true } } } `)) - http.StubResponse(200, bytes.NewBufferString(` + http.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "MonaLisa", "id": "MONAID" }, + "repository": { + "l000": { "name": "bug", "id": "BUGID" }, + "l001": { "name": "TODO", "id": "TODOID" } + } + } } + `)) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` { "data": { "createIssue": { "issue": { "URL": "https://github.com/OWNER/REPO/issues/12" } } } } - `)) + `, func(inputs map[string]interface{}) { + eq(t, inputs["title"], "recovered title") + eq(t, inputs["body"], "recovered body") + eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + })) - output, err := runCommand(http, true, `-j'{"title":"cool", "body":"issue"}'`) + as, teardown := prompt.InitAskStubber() + defer teardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "Body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + assert.NoError(t, err) + + state := prShared.IssueMetadataState{ + Title: "recovered title", + Body: "recovered body", + Labels: []string{"bug", "TODO"}, + } + + data, err := json.Marshal(state) + assert.NoError(t, err) + + _, err = tmpfile.Write(data) + assert.NoError(t, err) + + args := fmt.Sprintf("-e '%s'", tmpfile.Name()) + + output, err := runCommandWithRootDirOverridden(http, true, args, "") if err != nil { t.Errorf("error running command `issue create`: %v", err) } - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "cool") - eq(t, reqBody.Variables.Input.Body, "issue") - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 39d97dcd6..502de1126 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -38,10 +38,9 @@ type CreateOptions struct { RootDirOverride string RepoOverride string - Autofill bool - WebMode bool - JSONFill bool - JSONInput string + Autofill bool + WebMode bool + RecoverFile string IsDraft bool Title string @@ -101,12 +100,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { - opts.JSONFill = cmd.Flags().Changed("json") opts.TitleProvided = cmd.Flags().Changed("title") opts.BodyProvided = cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") - if !opts.IO.CanPrompt() && !opts.JSONFill && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { + if !opts.IO.CanPrompt() && opts.RecoverFile != "" { + return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")} + } + + if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")} } @@ -117,10 +119,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return errors.New("the --reviewer flag is not supported with --web") } - if opts.JSONFill && opts.WebMode { - return errors.New("--web and --json are mutually exclusive") - } - if runF != nil { return runF(opts) } @@ -141,7 +139,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") - fl.StringVarP(&opts.JSONInput, "json", "j", "", "Use JSON to populate and submit PR") + fl.StringVarP(&opts.RecoverFile, "recover", "e", "", "Recover input from a failed run of create") return cmd } @@ -212,18 +210,11 @@ func createRun(opts *CreateOptions) (err error) { return submitPR(*opts, *ctx, *state) } - if opts.JSONFill { - err = shared.FillFromJSON(opts.IO, opts.JSONInput, state) + if opts.RecoverFile != "" { + err = shared.FillFromJSON(opts.IO, opts.RecoverFile, state) if err != nil { - return fmt.Errorf("could not use JSON input: %w", err) + return fmt.Errorf("failed to recover input: %w", err) } - - err = handlePush(*opts, *ctx) - if err != nil { - return - } - - return submitPR(*opts, *ctx, *state) } if !opts.TitleProvided { @@ -242,11 +233,13 @@ func createRun(opts *CreateOptions) (err error) { templateContent := "" if !opts.BodyProvided { - templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") + if opts.RecoverFile == "" { + templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") - templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state) - if err != nil { - return + templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state) + if err != nil { + return + } } err = shared.BodySurvey(state, templateContent, editorCommand) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 3228d51f8..2c9fd6a41 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -3,8 +3,10 @@ package create import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" + "os" "reflect" "strings" "testing" @@ -136,20 +138,45 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { assert.Equal(t, "", output.String()) } -func TestPRCreate_json(t *testing.T) { +func TestPRCreate_recover(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.StubResponse(200, bytes.NewBufferString(` + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` { "data": { "repository": { "pullRequests": { "nodes" : [ ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` + `)) + http.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "jillValentine", "id": "JILLID" }, + "repository": {}, + "organization": {} + } } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), + httpmock.GraphQLMutation(` + { "data": { "requestReviews": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["userIds"], []interface{}{"JILLID"}) + })) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } - `)) + `, func(input map[string]interface{}) { + assert.Equal(t, "recovered title", input["title"].(string)) + assert.Equal(t, "recovered body", input["body"].(string)) + })) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -157,31 +184,48 @@ func TestPRCreate_json(t *testing.T) { cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - output, err := runCommand(http, nil, "feature", false, `-j'{"title":"cool", "body":"pr"}' -H feature`) - require.NoError(t, err) + as, teardown := prompt.InitAskStubber() + defer teardown() + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "Body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) + tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + assert.NoError(t, err) - assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID) - assert.Equal(t, "cool", reqBody.Variables.Input.Title) - assert.Equal(t, "pr", reqBody.Variables.Input.Body) - assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName) - assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName) + state := prShared.IssueMetadataState{ + Title: "recovered title", + Body: "recovered body", + Reviewers: []string{"jillValentine"}, + } - assert.Equal(t, "", output.Stderr()) - assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) + data, err := json.Marshal(state) + assert.NoError(t, err) + + _, err = tmpfile.Write(data) + assert.NoError(t, err) + + args := fmt.Sprintf("-e '%s' -Hfeature", tmpfile.Name()) + + output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "") + assert.NoError(t, err) + + eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_nontty(t *testing.T) { diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go index 8002e4a59..2bd5729ff 100644 --- a/pkg/cmd/pr/shared/preserve.go +++ b/pkg/cmd/pr/shared/preserve.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "github.com/cli/cli/pkg/iostreams" ) @@ -40,8 +39,6 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr return } - tmpfilePath := filepath.Join(os.TempDir(), tmpfile.Name()) - _, err = tmpfile.Write(data) if err != nil { fmt.Fprintf(out, "failed to save input to file: %s\n", err) @@ -57,8 +54,7 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr issueType = "issue" } - fmt.Fprintf(out, "%s operation failed. input saved to: %s\n", cs.FailureIcon(), tmpfilePath) - fmt.Fprintf(out, "resubmit with: gh %s create -j@%s\n", issueType, tmpfilePath) + fmt.Fprintf(out, "%s operation failed. recover with: gh %s create -e%s\n", cs.FailureIcon(), issueType, tmpfile.Name()) // some whitespace before the actual error fmt.Fprintln(out) diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index a4211e55f..fc6450164 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -5,7 +5,6 @@ import ( "errors" "io/ioutil" "os" - "path/filepath" "testing" "github.com/cli/cli/pkg/iostreams" @@ -18,7 +17,7 @@ func Test_PreserveInput(t *testing.T) { name string state *IssueMetadataState err bool - wantErrLines []string + wantErrLine string wantPreservation bool }{ { @@ -44,10 +43,7 @@ func Test_PreserveInput(t *testing.T) { Reviewers: []string{"barry", "chris"}, Labels: []string{"sandwich"}, }, - wantErrLines: []string{ - `X operation failed. input saved to:.*testfile.*`, - `resubmit with: gh issue create -j@.*testfile.*`, - }, + wantErrLine: `X operation failed. recover with: gh issue create -e.*testfile.*`, err: true, wantPreservation: true, }, @@ -57,10 +53,7 @@ func Test_PreserveInput(t *testing.T) { Reviewers: []string{"barry", "chris"}, Labels: []string{"sandwich"}, }, - wantErrLines: []string{ - `X operation failed. input saved to:.*testfile.*`, - `resubmit with: gh issue create -j@.*testfile.*`, - }, + wantErrLine: `X operation failed. recover with: gh issue create -e.*testfile.*`, err: true, wantPreservation: true, }, @@ -71,10 +64,7 @@ func Test_PreserveInput(t *testing.T) { Title: "a pull request", Type: PRMetadata, }, - wantErrLines: []string{ - `X operation failed. input saved to:.*testfile.*`, - `resubmit with: gh pr create -j@.*testfile.*`, - }, + wantErrLine: `X operation failed. recover with: gh pr create -e.*testfile.*`, err: true, wantPreservation: true, }, @@ -88,9 +78,9 @@ func Test_PreserveInput(t *testing.T) { io, _, _, errOut := iostreams.Test() - tfPath, tf, tferr := tmpfile() + tf, tferr := tmpfile() assert.NoError(t, tferr) - defer os.Remove(tfPath) + defer os.Remove(tf.Name()) io.TempFileOverride = tf @@ -108,7 +98,7 @@ func Test_PreserveInput(t *testing.T) { assert.NoError(t, err) if tt.wantPreservation { - test.ExpectLines(t, errOut.String(), tt.wantErrLines...) + test.ExpectLines(t, errOut.String(), tt.wantErrLine) preserved := &IssueMetadataState{} assert.NoError(t, json.Unmarshal(data, preserved)) preserved.dirty = tt.state.dirty @@ -121,12 +111,12 @@ func Test_PreserveInput(t *testing.T) { } } -func tmpfile() (string, *os.File, error) { +func tmpfile() (*os.File, error) { dir := os.TempDir() tmpfile, err := ioutil.TempFile(dir, "testfile*") if err != nil { - return "", nil, err + return nil, err } - return filepath.Join(dir, tmpfile.Name()), tmpfile, nil + return tmpfile, nil } diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go index 6c8e3531e..930a69fcc 100644 --- a/pkg/cmd/pr/shared/state.go +++ b/pkg/cmd/pr/shared/state.go @@ -3,7 +3,6 @@ package shared import ( "encoding/json" "fmt" - "strings" "github.com/cli/cli/api" "github.com/cli/cli/pkg/iostreams" @@ -52,16 +51,12 @@ func (tb *IssueMetadataState) HasMetadata() bool { len(tb.Milestones) > 0 } -func FillFromJSON(io *iostreams.IOStreams, JSONInput string, state *IssueMetadataState) error { +func FillFromJSON(io *iostreams.IOStreams, recoverFile string, state *IssueMetadataState) error { var data []byte var err error - if strings.HasPrefix(JSONInput, "@") { - data, err = io.ReadUserFile(JSONInput[1:]) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", JSONInput[1:], err) - } - } else { - data = []byte(JSONInput) + data, err = io.ReadUserFile(recoverFile) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", recoverFile, err) } err = json.Unmarshal(data, state) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index ae200e3d1..5df44098d 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -272,7 +272,6 @@ func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) { if s.TempFileOverride != nil { - fmt.Printf("DBG %#v\n", s.TempFileOverride) return s.TempFileOverride, nil } return ioutil.TempFile(dir, pattern) From cf37ce74634be6e6f5fc6b7f506141712d95b592 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 23 Nov 2020 11:20:27 -0800 Subject: [PATCH 032/129] no shorthand for --recover --- pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/issue/create/create_test.go | 2 +- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/shared/preserve.go | 2 +- pkg/cmd/pr/shared/preserve_test.go | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index a2912f638..44cac6ae1 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -88,7 +88,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") - cmd.Flags().StringVarP(&opts.RecoverFile, "recover", "e", "", "Recover input from a failed run of create") + cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") return cmd } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 733094964..d87411f18 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -199,7 +199,7 @@ func TestIssueCreate_recover(t *testing.T) { _, err = tmpfile.Write(data) assert.NoError(t, err) - args := fmt.Sprintf("-e '%s'", tmpfile.Name()) + args := fmt.Sprintf("--recover '%s'", tmpfile.Name()) output, err := runCommandWithRootDirOverridden(http, true, args, "") if err != nil { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 502de1126..8748582eb 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -139,7 +139,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") - fl.StringVarP(&opts.RecoverFile, "recover", "e", "", "Recover input from a failed run of create") + fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") return cmd } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 2c9fd6a41..7f703cef3 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -220,7 +220,7 @@ func TestPRCreate_recover(t *testing.T) { _, err = tmpfile.Write(data) assert.NoError(t, err) - args := fmt.Sprintf("-e '%s' -Hfeature", tmpfile.Name()) + args := fmt.Sprintf("--recover '%s' -Hfeature", tmpfile.Name()) output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "") assert.NoError(t, err) diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go index 2bd5729ff..4105823cd 100644 --- a/pkg/cmd/pr/shared/preserve.go +++ b/pkg/cmd/pr/shared/preserve.go @@ -54,7 +54,7 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr issueType = "issue" } - fmt.Fprintf(out, "%s operation failed. recover with: gh %s create -e%s\n", cs.FailureIcon(), issueType, tmpfile.Name()) + fmt.Fprintf(out, "%s operation failed. To restore: gh %s create --recover %s\n", cs.FailureIcon(), issueType, tmpfile.Name()) // some whitespace before the actual error fmt.Fprintln(out) diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go index fc6450164..28949d957 100644 --- a/pkg/cmd/pr/shared/preserve_test.go +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -43,7 +43,7 @@ func Test_PreserveInput(t *testing.T) { Reviewers: []string{"barry", "chris"}, Labels: []string{"sandwich"}, }, - wantErrLine: `X operation failed. recover with: gh issue create -e.*testfile.*`, + wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`, err: true, wantPreservation: true, }, @@ -53,7 +53,7 @@ func Test_PreserveInput(t *testing.T) { Reviewers: []string{"barry", "chris"}, Labels: []string{"sandwich"}, }, - wantErrLine: `X operation failed. recover with: gh issue create -e.*testfile.*`, + wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`, err: true, wantPreservation: true, }, @@ -64,7 +64,7 @@ func Test_PreserveInput(t *testing.T) { Title: "a pull request", Type: PRMetadata, }, - wantErrLine: `X operation failed. recover with: gh pr create -e.*testfile.*`, + wantErrLine: `X operation failed. To restore: gh pr create --recover .*testfile.*`, err: true, wantPreservation: true, }, From 9f84f0ffa1d5b3141399b7ed499eb81b69ef74d2 Mon Sep 17 00:00:00 2001 From: Shubhankar Kanchan Gupta Date: Tue, 24 Nov 2020 17:26:26 +0530 Subject: [PATCH 033/129] Warn termux users with older Android versions (#2467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- docs/install_linux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index d5392ba47..697bfb8f8 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -95,7 +95,7 @@ sudo pacman -S github-cli ### Android -Android users can install via Termux: +Android 7+ users can install via [Termux](https://wiki.termux.com/wiki/Main_Page): ```bash pkg install gh From ea50666c304f33774ff39a58cb8ffdf37b7b7a54 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Tue, 24 Nov 2020 13:49:04 -0300 Subject: [PATCH 034/129] Prompt: avoid resetting PR/issue metadata if no option is selected --- pkg/cmd/pr/shared/survey.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 4e4a72855..30d9073c7 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -345,17 +345,22 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - values := metadataValues{} - err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - state.Reviewers = values.Reviewers - state.Assignees = values.Assignees - state.Labels = values.Labels - state.Projects = values.Projects - if values.Milestone != "" && values.Milestone != noMilestone { - state.Milestones = []string{values.Milestone} + + if len(mqs) > 0 { + values := metadataValues{} + err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + state.Reviewers = values.Reviewers + state.Assignees = values.Assignees + state.Labels = values.Labels + state.Projects = values.Projects + if values.Milestone != "" && values.Milestone != noMilestone { + state.Milestones = []string{values.Milestone} + } + } else { + state.MetadataResult = nil } return nil From 37891a54d93a7c54e0a1d589399f3dda9589f535 Mon Sep 17 00:00:00 2001 From: Vixb Date: Wed, 25 Nov 2020 18:40:30 +0800 Subject: [PATCH 035/129] Update scoop install option (#2478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić Co-authored-by: Jan Pokorný --- README.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 146b44d1c..0c2ea4f48 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,9 @@ For more information and distro-specific instructions, see the [Linux installati #### scoop -Install: - -```powershell -scoop bucket add github-gh https://github.com/cli/scoop-gh.git -scoop install gh -``` - -Upgrade: - -```powershell -scoop update gh -``` +| Install: | Upgrade: | +| ------------------ | ------------------ | +| `scoop install gh` | `scoop update gh` | #### Chocolatey From 21e2544d73eb38435c1774ec231a118559062848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 25 Nov 2020 12:06:35 +0100 Subject: [PATCH 036/129] Sort latest PRs first when looking up PRs for a branch Fixes #2452 --- api/queries_pr.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index a95feab28..4433ef373 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -639,7 +639,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea query := ` query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) { repository(owner: $owner, name: $repo) { - pullRequests(headRefName: $headRefName, states: $states, first: 30) { + pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { id number From e9e8f207cc5c051b6a505dc0c3bb373a703556c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 25 Nov 2020 14:52:13 +0100 Subject: [PATCH 037/129] Bump AlecAivazis/survey --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ec44ad44e..bd4e69068 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.1.1 + github.com/AlecAivazis/survey/v2 v2.2.3 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 diff --git a/go.sum b/go.sum index fa1ef7c15..ba242e9dc 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI= -github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= +github.com/AlecAivazis/survey/v2 v2.2.3 h1:utJR2X4Ibp2fBxdjalQUiMFf3zfQNjA15YE8+ftlKEs= +github.com/AlecAivazis/survey/v2 v2.2.3/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= From ab05736b9806fce952df96e8ac953afa35ae3462 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Wed, 25 Nov 2020 13:30:54 -0300 Subject: [PATCH 038/129] don't reset previously added metadata --- pkg/cmd/pr/shared/survey.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 30d9073c7..d3838925e 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -347,11 +347,21 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo } if len(mqs) > 0 { - values := metadataValues{} + values := metadataValues{ + Reviewers: state.Reviewers, + Assignees: state.Assignees, + Labels: state.Labels, + Projects: state.Projects, + } + if len(state.Milestones) > 0 { + values.Milestone = state.Milestones[0] + } + err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) if err != nil { return fmt.Errorf("could not prompt: %w", err) } + state.Reviewers = values.Reviewers state.Assignees = values.Assignees state.Labels = values.Labels @@ -359,10 +369,10 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo if values.Milestone != "" && values.Milestone != noMilestone { state.Milestones = []string{values.Milestone} } - } else { - state.MetadataResult = nil } + state.MetadataResult = nil + return nil } From 436846a7154f5261349138604e5cb27ce0f9d6ac Mon Sep 17 00:00:00 2001 From: Amanda Pinsker Date: Wed, 25 Nov 2020 11:58:26 -0800 Subject: [PATCH 039/129] Add design system docs to contributing --- .github/CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c15d1e8b0..1ed4766ad 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -44,6 +44,10 @@ Please note that this project adheres to a [Contributor Code of Conduct][code-of We generate manual pages from source on every release. You do not need to submit pull requests for documentation specifically; manual pages for commands will automatically get updated after your pull requests gets accepted. +## Design guidelines + +You may reference the [CLI Design System][] when suggesting features, and are welcome to use our [Google Docs Template][] to suggest designs. + ## Resources - [How to Contribute to Open Source][] @@ -61,3 +65,5 @@ We generate manual pages from source on every release. You do not need to submit [How to Contribute to Open Source]: https://opensource.guide/how-to-contribute/ [Using Pull Requests]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests [GitHub Help]: https://docs.github.com/ +[CLI Design System]: https://primer.style/cli/ +[Google Docs Template]: https://docs.google.com/document/d/1JIRErIUuJ6fTgabiFYfCH3x91pyHuytbfa0QLnTfXKM/edit#heading=h.or54sa47ylpg From 1135e5e3ededb4261293b4e2b2fdeab6f79c0738 Mon Sep 17 00:00:00 2001 From: Zach Boyle <33520963+zaboyle@users.noreply.github.com> Date: Thu, 26 Nov 2020 05:54:28 -0500 Subject: [PATCH 040/129] set delete-branch merge flag default to false (#2466) Co-authored-by: Divya Ramanathan --- pkg/cmd/pr/merge/merge.go | 5 +---- pkg/cmd/pr/merge/merge_test.go | 24 +++--------------------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2eafe9712..d72ed2ca0 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -54,9 +54,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm Short: "Merge a pull request", Long: heredoc.Doc(` Merge a pull request on GitHub. - - By default, the head branch of the pull request will get deleted on both remote and local repositories. - To retain the branch, use '--delete-branch=false'. `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -102,7 +99,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm }, } - cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", true, "Delete the local and remote branch after merge") + cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge") cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch") cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch") cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch") diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 2815617d2..6cd0e19dc 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -38,7 +38,7 @@ func Test_NewCmdMerge(t *testing.T) { isTTY: true, want: MergeOptions{ SelectorArg: "123", - DeleteBranch: true, + DeleteBranch: false, DeleteLocalBranch: true, MergeMethod: api.PullRequestMergeMethodMerge, InteractiveMode: true, @@ -192,9 +192,6 @@ func TestPrMerge(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -238,9 +235,6 @@ func TestPrMerge_nontty(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -281,9 +275,6 @@ func TestPrMerge_withRepoFlag(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -385,9 +376,6 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -431,9 +419,6 @@ func TestPrMerge_rebase(t *testing.T) { assert.Equal(t, "REBASE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -476,9 +461,6 @@ func TestPrMerge_squash(t *testing.T) { assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string)) })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -493,7 +475,7 @@ func TestPrMerge_squash(t *testing.T) { t.Fatalf("error running command `pr merge`: %v", err) } - test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) + test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3") } func TestPrMerge_alreadyMerged(t *testing.T) { @@ -581,7 +563,7 @@ func TestPRMerge_interactive(t *testing.T) { t.Fatalf("Got unexpected error running `pr merge` %s", err) } - test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) + test.ExpectLines(t, output.Stderr(), "Merged pull request #3") } func TestPRMerge_interactiveCancelled(t *testing.T) { From 34d549e7b61660c7c993181c0be046d6277cad03 Mon Sep 17 00:00:00 2001 From: Max Horstmann Date: Thu, 26 Nov 2020 11:31:15 -0500 Subject: [PATCH 041/129] Document that reviewers can be teams (#2465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/pr/create/create.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 8748582eb..0b5b2b306 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -94,7 +94,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `), Example: heredoc.Doc(` $ gh pr create --title "The bug is fixed" --body "Everything works again" - $ gh pr create --reviewer monalisa,hubot + $ gh pr create --reviewer monalisa,hubot --reviewer myorg/team-name $ gh pr create --project "Roadmap" $ gh pr create --base develop --head monalisa:feature `), @@ -134,7 +134,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)") fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") - fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`") + fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`") fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") From da3287c26cc7e39cddee46363df8821a305c7a8c Mon Sep 17 00:00:00 2001 From: Ismael Luceno Date: Sat, 21 Nov 2020 21:46:32 +0100 Subject: [PATCH 042/129] Add make (un)install targets for POSIX systems The implementation imitates the behavior of build-systems generated by GNU Automake. Implemented targets: - install - install-strip - uninstall Implemented variables: - DESTDIR - prefix - bindir - INSTALL_STRIP_FLAG Internal implementation details: - install-bins variable collects user binaries to be installed - install-dirs variable collects directories to be created --- Makefile | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Makefile b/Makefile index 859461984..a5b5f815a 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ ifdef GH_OAUTH_CLIENT_SECRET GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS) endif +install-bins += bin/gh bin/gh: $(BUILD_FILES) @go build -trimpath -ldflags "$(GO_LDFLAGS)" -o "$@" ./cmd/gh @@ -62,3 +63,20 @@ endif .PHONY: manpages manpages: go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/ + +DESTDIR := +prefix := /usr/local +bindir := ${prefix}/bin + +.PHONY: install install-strip uninstall +INSTALL_STRIP_FLAG = +install-strip: + @${MAKE} INSTALL_STRIP_FLAG=-s install + +install: ${install-bins} + install -d ${DESTDIR}${bindir} + install -m555 ${INSTALL_STRIP_FLAG} ${install-bins} ${DESTDIR}${bindir}/ + +remove-bins := ${install-bins:bin/%=${DESTDIR}${bindir}/%} +uninstall: + rm -f ${remove-bins} From 8d2881d5ea4412439179ca748e7ba47a70e6e4af Mon Sep 17 00:00:00 2001 From: Ismael Luceno Date: Sun, 29 Nov 2020 20:36:32 +0100 Subject: [PATCH 043/129] Install manual pages --- .gitignore | 1 + Makefile | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 9895939a6..8f460efe7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /site .github/**/node_modules /CHANGELOG.md +/manpages.list # VS Code .vscode diff --git a/Makefile b/Makefile index a5b5f815a..b6db4eab9 100644 --- a/Makefile +++ b/Makefile @@ -61,22 +61,28 @@ endif .PHONY: manpages -manpages: +manpages: manpages.list +manpages.list: go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/ + find share/man -type f > $@ DESTDIR := prefix := /usr/local bindir := ${prefix}/bin +mandir := ${prefix}/share/man .PHONY: install install-strip uninstall INSTALL_STRIP_FLAG = install-strip: @${MAKE} INSTALL_STRIP_FLAG=-s install -install: ${install-bins} +install: ${install-bins} manpages.list install -d ${DESTDIR}${bindir} install -m555 ${INSTALL_STRIP_FLAG} ${install-bins} ${DESTDIR}${bindir}/ + install -d ${DESTDIR}${mandir}/man1 + install -m444 $(shell cat manpages.list) ${DESTDIR}${mandir}/man1/ remove-bins := ${install-bins:bin/%=${DESTDIR}${bindir}/%} -uninstall: +uninstall: manpages.list rm -f ${remove-bins} + rm -f $(patsubst share/man/%,${DESTDIR}${mandir}/%,$(shell cat manpages.list)) From 413ccb71cce962dfeddbda86a2a4dab563efa630 Mon Sep 17 00:00:00 2001 From: Nils Leif Fischer Date: Sun, 29 Nov 2020 16:07:43 +0100 Subject: [PATCH 044/129] Delete an error message that is not useful (and had a typo) --- cmd/gh/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index ec505d4b8..61cbf7081 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -140,7 +140,6 @@ func main() { fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!")) fmt.Fprintln(stderr) fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.") - fmt.Fprintln(stderr, "You can also set the one of the auth token environment variables, if preferred.") os.Exit(4) } From dc1fad9cb093959bf3b9a4a388964b242bef7d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 15:28:39 +0100 Subject: [PATCH 045/129] Fix respecting chosen action in interactive `issue create` The `action` variable started being shadowed in the `if` block in 6671106448ed7b342797b426e15af032dfe3b1be --- pkg/cmd/issue/create/create.go | 1 - pkg/cmd/issue/create/create_test.go | 57 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 44cac6ae1..8df05d723 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -199,7 +199,6 @@ func createRun(opts *CreateOptions) (err error) { } } - var action prShared.Action action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage()) if err != nil { err = fmt.Errorf("unable to confirm: %w", err) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index d87411f18..3a62e12e5 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" @@ -274,6 +275,62 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } +func TestIssueCreate_continueInBrowser(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + + as, teardown := prompt.InitAskStubber() + defer teardown() + + // title + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Value: "hello", + }, + }) + // confirm + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 1, + }, + }) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, `-b body`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + + Creating issue in OWNER/REPO + + Opening github.com/OWNER/REPO/issues/new in your browser. + `), output.Stderr()) + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", url) +} + func TestIssueCreate_metadata(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) From c92f416cc0a590cdef6e6b8bef88b91306e66a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 15:46:18 +0100 Subject: [PATCH 046/129] Simplify `make install/uninstall` --- .gitignore | 1 - Makefile | 25 ++++++++----------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 8f460efe7..9895939a6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ /site .github/**/node_modules /CHANGELOG.md -/manpages.list # VS Code .vscode diff --git a/Makefile b/Makefile index b6db4eab9..8ee9e7dc6 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,6 @@ ifdef GH_OAUTH_CLIENT_SECRET GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS) endif -install-bins += bin/gh bin/gh: $(BUILD_FILES) @go build -trimpath -ldflags "$(GO_LDFLAGS)" -o "$@" ./cmd/gh @@ -59,30 +58,22 @@ endif git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html .PHONY: site-bump - .PHONY: manpages -manpages: manpages.list -manpages.list: +manpages: go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/ - find share/man -type f > $@ DESTDIR := prefix := /usr/local bindir := ${prefix}/bin mandir := ${prefix}/share/man -.PHONY: install install-strip uninstall -INSTALL_STRIP_FLAG = -install-strip: - @${MAKE} INSTALL_STRIP_FLAG=-s install - -install: ${install-bins} manpages.list +.PHONY: install +install: bin/gh manpages install -d ${DESTDIR}${bindir} - install -m555 ${INSTALL_STRIP_FLAG} ${install-bins} ${DESTDIR}${bindir}/ + install -m755 bin/gh ${DESTDIR}${bindir}/ install -d ${DESTDIR}${mandir}/man1 - install -m444 $(shell cat manpages.list) ${DESTDIR}${mandir}/man1/ + install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/ -remove-bins := ${install-bins:bin/%=${DESTDIR}${bindir}/%} -uninstall: manpages.list - rm -f ${remove-bins} - rm -f $(patsubst share/man/%,${DESTDIR}${mandir}/%,$(shell cat manpages.list)) +.PHONY: uninstall +uninstall: + rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1 From df2ca9c9f9e98a17e4926854bd2ab7c9d916475e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 15:55:40 +0100 Subject: [PATCH 047/129] Fix browser URL test on Windows --- pkg/cmd/issue/create/create_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 3a62e12e5..d7e6b4ec0 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -327,7 +327,7 @@ func TestIssueCreate_continueInBrowser(t *testing.T) { if seenCmd == nil { t.Fatal("expected a command to run") } - url := seenCmd.Args[len(seenCmd.Args)-1] + url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", url) } From e21c5100fa02696073100a9a94684bd889ad4c6f Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 1 Dec 2020 11:44:14 -0500 Subject: [PATCH 048/129] Properly check env auth tokens in CheckAuth --- internal/config/from_env.go | 7 ++++ internal/config/from_env_test.go | 57 ++++++++++++++++++++++++++++++++ pkg/cmdutil/auth_check.go | 4 +++ pkg/cmdutil/auth_check_test.go | 21 ++++++++++++ 4 files changed, 89 insertions(+) diff --git a/internal/config/from_env.go b/internal/config/from_env.go index da4ac1536..333dc879b 100644 --- a/internal/config/from_env.go +++ b/internal/config/from_env.go @@ -78,3 +78,10 @@ func AuthTokenFromEnv(hostname string) (string, string) { return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN } + +func AuthTokenProvidedFromEnv() bool { + return os.Getenv(GH_ENTERPRISE_TOKEN) != "" || + os.Getenv(GITHUB_ENTERPRISE_TOKEN) != "" || + os.Getenv(GH_TOKEN) != "" || + os.Getenv(GITHUB_TOKEN) != "" +} diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go index baeb63194..c280b8a90 100644 --- a/internal/config/from_env_test.go +++ b/internal/config/from_env_test.go @@ -283,3 +283,60 @@ func TestInheritEnv(t *testing.T) { }) } } + +func TestAuthTokenProvidedFromEnv(t *testing.T) { + orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + orig_GH_TOKEN := os.Getenv("GH_TOKEN") + orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN") + t.Cleanup(func() { + os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN) + os.Setenv("GH_TOKEN", orig_GH_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN) + }) + + tests := []struct { + name string + GITHUB_TOKEN string + GITHUB_ENTERPRISE_TOKEN string + GH_TOKEN string + GH_ENTERPRISE_TOKEN string + provided bool + }{ + { + name: "no env tokens", + provided: false, + }, + { + name: "GH_TOKEN", + GH_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GITHUB_TOKEN", + GITHUB_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GH_ENTERPRISE_TOKEN", + GH_ENTERPRISE_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN", + GITHUB_ENTERPRISE_TOKEN: "TOKEN", + provided: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) + os.Setenv("GH_TOKEN", tt.GH_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN) + assert.Equal(t, tt.provided, AuthTokenProvidedFromEnv()) + }) + } +} diff --git a/pkg/cmdutil/auth_check.go b/pkg/cmdutil/auth_check.go index 5d40d0143..10df9fade 100644 --- a/pkg/cmdutil/auth_check.go +++ b/pkg/cmdutil/auth_check.go @@ -17,6 +17,10 @@ func DisableAuthCheck(cmd *cobra.Command) { } func CheckAuth(cfg config.Config) bool { + if config.AuthTokenProvidedFromEnv() { + return true + } + hosts, err := cfg.Hosts() if err != nil { return false diff --git a/pkg/cmdutil/auth_check_test.go b/pkg/cmdutil/auth_check_test.go index 22b8ff5d4..2798750f0 100644 --- a/pkg/cmdutil/auth_check_test.go +++ b/pkg/cmdutil/auth_check_test.go @@ -1,6 +1,7 @@ package cmdutil import ( + "os" "testing" "github.com/cli/cli/internal/config" @@ -8,21 +9,34 @@ import ( ) func Test_CheckAuth(t *testing.T) { + orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + t.Cleanup(func() { + os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) + }) + tests := []struct { name string cfg func(config.Config) + envToken bool expected bool }{ { name: "no hosts", cfg: func(c config.Config) {}, + envToken: false, expected: false, }, + {name: "no hosts, env auth token", + cfg: func(c config.Config) {}, + envToken: true, + expected: true, + }, { name: "host, no token", cfg: func(c config.Config) { _ = c.Set("github.com", "oauth_token", "") }, + envToken: false, expected: false, }, { @@ -30,12 +44,19 @@ func Test_CheckAuth(t *testing.T) { cfg: func(c config.Config) { _ = c.Set("github.com", "oauth_token", "a token") }, + envToken: false, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.envToken { + os.Setenv("GITHUB_TOKEN", "TOKEN") + } else { + os.Setenv("GITHUB_TOKEN", "") + } + cfg := config.NewBlankConfig() tt.cfg(cfg) result := CheckAuth(cfg) From 6f689ff051d0be160fb3014d57f3162722ac658d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 20:31:20 +0100 Subject: [PATCH 049/129] Document `make install` --- docs/source.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/source.md b/docs/source.md index fc90ef94f..1c6a8470a 100644 --- a/docs/source.md +++ b/docs/source.md @@ -15,16 +15,18 @@ $ cd gh-cli ``` -2. Build the project - - ``` - $ make - ``` - -3. Move the resulting `bin/gh` executable to somewhere in your PATH +2. Build and install ```sh - $ sudo mv ./bin/gh /usr/local/bin/ + # installs to '/usr/local' by default; sudo may be required + $ make install ``` -4. Run `gh version` to check if it worked. + To install to a different location: + ```sh + $ make install prefix=/path/to/gh + ``` + + Make sure that the `${prefix}/bin` directory is in your PATH. + +3. Run `gh version` to check if it worked. From be759785f0b47bcc4288df03f69e186a127c5202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 21:23:39 +0100 Subject: [PATCH 050/129] Fix "Continue in browser" for `pr create` coming from forks Ensures that the `owner:` prefix is present when referencing the head branch --- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 8748582eb..e58cd5b1d 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -684,7 +684,7 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str u := ghrepo.GenerateRepoURL( ctx.BaseRepo, "compare/%s...%s?expand=1", - url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranch)) + url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranchLabel)) url, err := shared.WithPrAndIssueQueryParams(u, state) if err != nil { return "", err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 7f703cef3..171207ea8 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -827,9 +827,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "basic", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "main", - HeadBranch: "feature", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "main", + HeadBranchLabel: "feature", }, want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1", wantErr: false, @@ -837,9 +837,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "with labels", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "a", - HeadBranch: "b", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "a", + HeadBranchLabel: "b", }, state: prShared.IssueMetadataState{ Labels: []string{"one", "two three"}, @@ -850,9 +850,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "complex branch names", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "main/trunk", - HeadBranch: "owner:feature", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "main/trunk", + HeadBranchLabel: "owner:feature", }, want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1", wantErr: false, From d6add864b8b08497c5cb127e8d97d3e56348618b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 2 Dec 2020 17:20:07 +0100 Subject: [PATCH 051/129] Ensure efficient resolving of `issue/pr create` metadata to GraphQL IDs For metadata types chosen in interactive flow, we fetch all records from the API in order to be able to display a multi-select interface. For metadata defined via command-line flags, we resolve records that can be looked up directly, avoiding fetching the entirety of expensive datasets (e.g. all members of an organization) if we can. The new approach ensures efficient fetching when interactive flow is combined with values from flags. --- api/queries_repo.go | 22 ++++++++++++++ pkg/cmd/pr/shared/params.go | 56 ++++++++++++++++++++++++++--------- pkg/cmd/pr/shared/survey.go | 59 ++++++++++++++++++------------------- 3 files changed, 93 insertions(+), 44 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 54f88a7e1..b60dfaa92 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -464,6 +464,28 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { return "", errors.New("not found") } +func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) { + if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 { + m.AssignableUsers = m2.AssignableUsers + } + + if len(m2.Teams) > 0 || len(m.Teams) == 0 { + m.Teams = m2.Teams + } + + if len(m2.Labels) > 0 || len(m.Labels) == 0 { + m.Labels = m2.Labels + } + + if len(m2.Projects) > 0 || len(m.Projects) == 0 { + m.Projects = m2.Projects + } + + if len(m2.Milestones) > 0 || len(m.Milestones) == 0 { + m.Milestones = m2.Milestones + } +} + type RepoMetadataInput struct { Assignees bool Reviewers bool diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 9edb9e9e7..6efdf9494 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -37,25 +37,53 @@ func WithPrAndIssueQueryParams(baseURL string, state IssueMetadataState) (string return u.String(), nil } +// Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able +// to resolve all object listed in tb to GraphQL IDs. +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error { + resolveInput := api.RepoResolveInput{} + + if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { + resolveInput.Assignees = tb.Assignees + } + + if len(tb.Reviewers) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { + resolveInput.Reviewers = tb.Reviewers + } + + if len(tb.Labels) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Labels) == 0) { + resolveInput.Labels = tb.Labels + } + + if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { + resolveInput.Projects = tb.Projects + } + + if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) { + resolveInput.Milestones = tb.Milestones + } + + metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) + if err != nil { + return err + } + + if tb.MetadataResult == nil { + tb.MetadataResult = metadataResult + } else { + tb.MetadataResult.Merge(metadataResult) + } + + return nil +} + func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error { if !tb.HasMetadata() { return nil } - if tb.MetadataResult == nil { - resolveInput := api.RepoResolveInput{ - Reviewers: tb.Reviewers, - Assignees: tb.Assignees, - Labels: tb.Labels, - Projects: tb.Projects, - Milestones: tb.Milestones, - } - - var err error - tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) - if err != nil { - return err - } + err := fillMetadata(client, baseRepo, tb) + if err != nil { + return err } assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index d3838925e..161d662b4 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -263,13 +263,6 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo milestones = append(milestones, m.Title) } - type metadataValues struct { - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestone string - } var mqs []*survey.Question if isChosen("Reviewers") { if len(users) > 0 || len(teams) > 0 { @@ -346,32 +339,38 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo } } - if len(mqs) > 0 { - values := metadataValues{ - Reviewers: state.Reviewers, - Assignees: state.Assignees, - Labels: state.Labels, - Projects: state.Projects, - } - if len(state.Milestones) > 0 { - values.Milestone = state.Milestones[0] - } + values := struct { + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + }{} - err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - - state.Reviewers = values.Reviewers - state.Assignees = values.Assignees - state.Labels = values.Labels - state.Projects = values.Projects - if values.Milestone != "" && values.Milestone != noMilestone { - state.Milestones = []string{values.Milestone} - } + err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) } - state.MetadataResult = nil + if isChosen("Reviewers") { + state.Reviewers = values.Reviewers + } + if isChosen("Assignees") { + state.Assignees = values.Assignees + } + if isChosen("Labels") { + state.Labels = values.Labels + } + if isChosen("Projects") { + state.Projects = values.Projects + } + if isChosen("Milestone") { + if values.Milestone != "" && values.Milestone != noMilestone { + state.Milestones = []string{values.Milestone} + } else { + state.Milestones = []string{} + } + } return nil } From be39f4363bfc14078800ebda3d8ddae7e0424ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Dec 2020 17:47:40 +0100 Subject: [PATCH 052/129] Make MetadataSurvey testable by accepting an interface --- pkg/cmd/issue/create/create.go | 8 +- pkg/cmd/pr/create/create.go | 8 +- pkg/cmd/pr/shared/survey.go | 37 +++++--- pkg/cmd/pr/shared/survey_test.go | 144 +++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 pkg/cmd/pr/shared/survey_test.go diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 44cac6ae1..a098b31fa 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -207,7 +207,13 @@ func createRun(opts *CreateOptions) (err error) { } if action == prShared.MetadataAction { - err = prShared.MetadataSurvey(opts.IO, apiClient, baseRepo, &tb) + fetcher := &prShared.MetadataFetcher{ + IO: opts.IO, + APIClient: apiClient, + Repo: baseRepo, + State: &tb, + } + err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb) if err != nil { return } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 8748582eb..faea5f13d 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -259,7 +259,13 @@ func createRun(opts *CreateOptions) (err error) { } if action == shared.MetadataAction { - err = shared.MetadataSurvey(opts.IO, client, ctx.BaseRepo, state) + fetcher := &shared.MetadataFetcher{ + IO: opts.IO, + APIClient: client, + Repo: ctx.BaseRepo, + State: state, + } + err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state) if err != nil { return } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 161d662b4..8ef40d047 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -12,7 +12,6 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" - "github.com/cli/cli/utils" ) type Action int @@ -196,7 +195,26 @@ func TitleSurvey(state *IssueMetadataState) error { return nil } -func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo.Interface, state *IssueMetadataState) error { +type MetadataFetcher struct { + IO *iostreams.IOStreams + APIClient *api.Client + Repo ghrepo.Interface + State *IssueMetadataState +} + +func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + mf.IO.StartProgressIndicator() + metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input) + mf.IO.StopProgressIndicator() + mf.State.MetadataResult = metadataResult + return metadataResult, err +} + +type RepoMetadataFetcher interface { + RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) +} + +func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -234,32 +252,29 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - s := utils.Spinner(io.ErrOut) - utils.StartSpinner(s) - state.MetadataResult, err = api.RepoMetadata(client, baseRepo, metadataInput) - utils.StopSpinner(s) + metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } var users []string - for _, u := range state.MetadataResult.AssignableUsers { + for _, u := range metadataResult.AssignableUsers { users = append(users, u.Login) } var teams []string - for _, t := range state.MetadataResult.Teams { + for _, t := range metadataResult.Teams { teams = append(teams, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug)) } var labels []string - for _, l := range state.MetadataResult.Labels { + for _, l := range metadataResult.Labels { labels = append(labels, l.Name) } var projects []string - for _, l := range state.MetadataResult.Projects { + for _, l := range metadataResult.Projects { projects = append(projects, l.Name) } milestones := []string{noMilestone} - for _, m := range state.MetadataResult.Milestones { + for _, m := range metadataResult.Milestones { milestones = append(milestones, m.Title) } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go new file mode 100644 index 000000000..a500040d3 --- /dev/null +++ b/pkg/cmd/pr/shared/survey_test.go @@ -0,0 +1,144 @@ +package shared + +import ( + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/stretchr/testify/assert" +) + +type metadataFetcher struct { + metadataResult *api.RepoMetadataResult +} + +func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + return mf.metadataResult, nil +} + +func TestMetadataSurvey_selectAll(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &metadataFetcher{ + metadataResult: &api.RepoMetadataResult{ + AssignableUsers: []api.RepoAssignee{ + {Login: "hubot"}, + {Login: "monalisa"}, + }, + Labels: []api.RepoLabel{ + {Name: "help wanted"}, + {Name: "good first issue"}, + }, + Projects: []api.RepoProject{ + {Name: "Huge Refactoring"}, + {Name: "The road to 1.0"}, + }, + Milestones: []api.RepoMilestone{ + {Title: "1.2 patch release"}, + }, + }, + } + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "metadata", + Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "reviewers", + Value: []string{"monalisa"}, + }, + { + Name: "assignees", + Value: []string{"hubot"}, + }, + { + Name: "labels", + Value: []string{"good first issue"}, + }, + { + Name: "projects", + Value: []string{"The road to 1.0"}, + }, + { + Name: "milestone", + Value: []string{"(none)"}, + }, + }) + + state := &IssueMetadataState{ + Assignees: []string{"hubot"}, + } + err := MetadataSurvey(io, repo, fetcher, state) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, []string{"hubot"}, state.Assignees) + assert.Equal(t, []string{"monalisa"}, state.Reviewers) + assert.Equal(t, []string{"good first issue"}, state.Labels) + assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{}, state.Milestones) +} + +func TestMetadataSurvey_keepExisting(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &metadataFetcher{ + metadataResult: &api.RepoMetadataResult{ + Labels: []api.RepoLabel{ + {Name: "help wanted"}, + {Name: "good first issue"}, + }, + Projects: []api.RepoProject{ + {Name: "Huge Refactoring"}, + {Name: "The road to 1.0"}, + }, + }, + } + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "metadata", + Value: []string{"Labels", "Projects"}, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "labels", + Value: []string{"good first issue"}, + }, + { + Name: "projects", + Value: []string{"The road to 1.0"}, + }, + }) + + state := &IssueMetadataState{ + Assignees: []string{"hubot"}, + } + err := MetadataSurvey(io, repo, fetcher, state) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, []string{"hubot"}, state.Assignees) + assert.Equal(t, []string{"good first issue"}, state.Labels) + assert.Equal(t, []string{"The road to 1.0"}, state.Projects) +} From 2b4372bc3a6f4dd6d69293369d54d1c0c0294822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Dec 2020 17:51:58 +0100 Subject: [PATCH 053/129] AskStubber now throws a more descriptive error when stubs do not match --- pkg/prompt/stubber.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go index a3302c3f9..be920cd25 100644 --- a/pkg/prompt/stubber.go +++ b/pkg/prompt/stubber.go @@ -51,6 +51,9 @@ func InitAskStubber() (*AskStubber, func()) { // actually set response stubbedQuestions := as.Stubs[count] + if len(stubbedQuestions) != len(qs) { + panic(fmt.Sprintf("asked questions: %d; stubbed questions: %d", len(qs), len(stubbedQuestions))) + } for i, sq := range stubbedQuestions { q := qs[i] if q.Name != sq.Name { From 8db2027c99c669e9ed5980e6bf33700815240286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Dec 2020 18:02:24 +0100 Subject: [PATCH 054/129] Allow interactive `pr create` even if we failed to look up commits --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index faea5f13d..68ab074c6 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -386,7 +386,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided { err := initDefaultTitleBody(ctx, state) - if err != nil { + if err != nil && opts.Autofill { return nil, fmt.Errorf("could not compute title or body defaults: %w", err) } } From 5309a2089a2600fa4ac475f4570b9622718ee8a7 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 24 Nov 2020 12:07:54 -0800 Subject: [PATCH 055/129] implement gh secret create and gh secret list --- pkg/cmd/gist/create/create.go | 4 +- pkg/cmd/root/root.go | 2 + pkg/cmd/secret/create/create.go | 183 +++++++++++++++ pkg/cmd/secret/create/create_test.go | 334 +++++++++++++++++++++++++++ pkg/cmd/secret/create/http.go | 138 +++++++++++ pkg/cmd/secret/list/list.go | 156 +++++++++++++ pkg/cmd/secret/list/list_test.go | 227 ++++++++++++++++++ pkg/cmd/secret/secret.go | 28 +++ pkg/cmd/secret/shared/shared.go | 7 + 9 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/secret/create/create.go create mode 100644 pkg/cmd/secret/create/create_test.go create mode 100644 pkg/cmd/secret/create/http.go create mode 100644 pkg/cmd/secret/list/list.go create mode 100644 pkg/cmd/secret/list/list_test.go create mode 100644 pkg/cmd/secret/secret.go create mode 100644 pkg/cmd/secret/shared/shared.go diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index ee2976b80..dd46b6edd 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -227,8 +227,8 @@ func createGist(client *http.Client, hostname, description string, public bool, } requestBody := bytes.NewReader(requestByte) - apliClient := api.NewClientFromHTTP(client) - err = apliClient.REST(hostname, "POST", path, requestBody, &result) + apiClient := api.NewClientFromHTTP(client) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 42c1b627f..d5e509665 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -19,6 +19,7 @@ import ( releaseCmd "github.com/cli/cli/pkg/cmd/release" repoCmd "github.com/cli/cli/pkg/cmd/repo" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + secretCmd "github.com/cli/cli/pkg/cmd/secret" versionCmd "github.com/cli/cli/pkg/cmd/version" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -74,6 +75,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) + cmd.AddCommand(secretCmd.NewCmdSecret(f)) // the `api` command should not inherit any extra HTTP headers bareHTTPCmdFactory := *f diff --git a/pkg/cmd/secret/create/create.go b/pkg/cmd/secret/create/create.go new file mode 100644 index 000000000..bdd8d46be --- /dev/null +++ b/pkg/cmd/secret/create/create.go @@ -0,0 +1,183 @@ +package create + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "golang.org/x/crypto/nacl/box" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RandomOverride io.Reader + + SecretName string + OrgName string + Body string + Visibility string + RepositoryNames []string +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create secrets", + Long: "Locally encrypt a new secret and send it to GitHub for storage.", + Example: heredoc.Doc(` + $ cat SECRET.txt | gh secret create NEW_SECRET + $ gh secret create NEW_SECRET -b"some literal value" + $ gh secret create NEW_SECRET -b"@file.json" + $ gh secret create ORG_SECRET --org + $ gh secret create ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3" + $ gh secret create ORG_SECRET --org=anotherOrg --visibility="all" +`), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return &cmdutil.FlagError{Err: errors.New("must pass single secret name")} + } + if !cmd.Flags().Changed("body") && opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("no --body specified but nothing on STIDN")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SecretName = args[0] + + if cmd.Flags().Changed("visibility") { + if opts.OrgName == "" { + return &cmdutil.FlagError{Err: errors.New( + "--visibility not supported for repository secrets; did you mean to pass --org?")} + } + + if opts.Visibility != shared.VisAll && opts.Visibility != shared.VisPrivate && opts.Visibility != shared.VisSelected { + return &cmdutil.FlagError{Err: errors.New( + "--visibility must be one of `all`, `private`, or `selected`")} + } + } + + if cmd.Flags().Changed("repos") && opts.Visibility != shared.VisSelected { + return &cmdutil.FlagError{Err: errors.New( + "--repos only supported when --visibility='selected'")} + } + + if opts.Visibility == shared.VisSelected && len(opts.RepositoryNames) == 0 { + return &cmdutil.FlagError{Err: errors.New( + "--repos flag required when --visibility='selected'")} + } + + if runF != nil { + return runF(opts) + } + + return createRun(opts) + }, + } + cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") + cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") + cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "-", "Provide either a literal string or a file path; prepend file paths with an @. Reads from STDIN if not provided.") + + return cmd +} + +func createRun(opts *CreateOptions) error { + body, err := getBody(opts) + if err != nil { + return fmt.Errorf("did not understand secret body: %w", err) + } + + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + var baseRepo ghrepo.Interface + if opts.OrgName == "" || opts.OrgName == "@owner" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + host := ghinstance.OverridableDefault() + if opts.OrgName == "@owner" { + opts.OrgName = baseRepo.RepoOwner() + host = baseRepo.RepoHost() + } + + var pk *PubKey + if opts.OrgName != "" { + pk, err = getOrgPublicKey(client, host, opts.OrgName) + } else { + pk, err = getRepoPubKey(client, baseRepo) + } + if err != nil { + return fmt.Errorf("failed to fetch public key: %w", err) + } + + eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride) + if err != nil { + return fmt.Errorf("failed to encrypt body: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(eBody) + + if opts.OrgName != "" { + err = putOrgSecret(client, pk, host, *opts, encoded) + } else { + err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) + } + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + return nil +} + +func getBody(opts *CreateOptions) (body []byte, err error) { + if opts.Body == "-" { + body, err = ioutil.ReadAll(opts.IO.In) + if err != nil { + return nil, fmt.Errorf("failed to read from STDIN: %w", err) + } + + return + } + + if strings.HasPrefix(opts.Body, "@") { + body, err = opts.IO.ReadUserFile(opts.Body[1:]) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", opts.Body[1:], err) + } + + return + } + + return []byte(opts.Body), nil +} diff --git a/pkg/cmd/secret/create/create_test.go b/pkg/cmd/secret/create/create_test.go new file mode 100644 index 000000000..365fd84a5 --- /dev/null +++ b/pkg/cmd/secret/create/create_test.go @@ -0,0 +1,334 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/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" +) + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + cli string + wants CreateOptions + stdinTTY bool + wantsErr bool + }{ + { + name: "invalid visibility", + cli: "cool_secret --org -v'mistyVeil'", + wantsErr: true, + }, + { + name: "invalid visibility", + cli: "cool_secret --org -v'selected'", + wantsErr: true, + }, + { + name: "no name", + cli: "", + wantsErr: true, + }, + { + name: "multiple names", + cli: "cool_secret good_secret", + wantsErr: true, + }, + { + name: "no body, stdin is terminal", + cli: "cool_secret", + stdinTTY: true, + wantsErr: true, + }, + { + name: "visibility without org", + cli: "cool_secret -vall", + wantsErr: true, + }, + { + name: "explicit org with selected repo", + cli: "--org=coolOrg -vselected -rcoolRepo cool_secret", + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisSelected, + RepositoryNames: []string{"coolRepo"}, + Body: "-", + OrgName: "coolOrg", + }, + }, + { + name: "explicit org with selected repos", + cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisSelected, + RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, + Body: "-", + OrgName: "coolOrg", + }, + }, + { + name: "repo", + cli: `cool_secret -b"a secret"`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisPrivate, + Body: "a secret", + OrgName: "", + }, + }, + { + name: "implicit org", + cli: `cool_secret --org -b"@cool.json"`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisPrivate, + Body: "@cool.json", + OrgName: "@owner", + }, + }, + { + name: "vis all", + cli: `cool_secret --org -b"@cool.json" -vall`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisAll, + Body: "@cool.json", + OrgName: "@owner", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + io.SetStdinTTY(tt.stdinTTY) + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *CreateOptions + cmd := NewCmdCreate(f, func(opts *CreateOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName) + assert.Equal(t, tt.wants.Body, gotOpts.Body) + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames) + }) + } +} + +func Test_createRun_repo(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`)) + + mockClient := func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, _, _ := iostreams.Test() + + opts := &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + HttpClient: mockClient, + IO: io, + SecretName: "cool_secret", + Body: "a secret", + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}), + } + + err := createRun(opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") +} + +func Test_createRun_org(t *testing.T) { + tests := []struct { + name string + opts *CreateOptions + wantVisibility string + wantRepositories []int + }{ + { + name: "explicit org name", + opts: &CreateOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.VisAll, + }, + }, + { + name: "implicit org name", + opts: &CreateOptions{ + OrgName: "@owner", + Visibility: shared.VisPrivate, + }, + }, + { + name: "selected visibility", + opts: &CreateOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.VisSelected, + RepositoryNames: []string{"birkin", "wesker"}, + }, + wantRepositories: []int{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + orgName := tt.opts.OrgName + if orgName == "@owner" { + orgName = "NeoUmbrella" + } + + reg.Register(httpmock.REST("GET", + fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", + fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)), + httpmock.StatusStringResponse(201, `{}`)) + + if len(tt.opts.RepositoryNames) > 0 { + reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`), + httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`)) + } + + io, _, _, _ := iostreams.Test() + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("NeoUmbrella/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.IO = io + tt.opts.SecretName = "cool_secret" + tt.opts.Body = "a secret" + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}) + + err := createRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + assert.Equal(t, payload.Visibility, tt.opts.Visibility) + assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories) + }) + } +} + +func Test_getBody(t *testing.T) { + tests := []struct { + name string + bodyArg string + want string + stdin string + fromFile bool + }{ + { + name: "literal value", + bodyArg: "a secret", + want: "a secret", + }, + { + name: "from stdin", + bodyArg: "-", + want: "a secret", + stdin: "a secret", + }, + { + name: "from file", + fromFile: true, + want: "a secret from a file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, _, _ := iostreams.Test() + + io.SetStdinTTY(false) + + _, err := stdin.WriteString(tt.stdin) + assert.NoError(t, err) + + if tt.fromFile { + dir := os.TempDir() + tmpfile, err := ioutil.TempFile(dir, "testfile*") + assert.NoError(t, err) + _, err = tmpfile.WriteString(tt.want) + assert.NoError(t, err) + tt.bodyArg = fmt.Sprintf("@%s", tmpfile.Name()) + } + + body, err := getBody(&CreateOptions{ + Body: tt.bodyArg, + IO: io, + }) + assert.NoError(t, err) + + assert.Equal(t, string(body), tt.want) + + }) + + } + +} diff --git a/pkg/cmd/secret/create/http.go b/pkg/cmd/secret/create/http.go new file mode 100644 index 000000000..51d270b83 --- /dev/null +++ b/pkg/cmd/secret/create/http.go @@ -0,0 +1,138 @@ +package create + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" +) + +type SecretPayload struct { + EncryptedValue string `json:"encrypted_value"` + Visibility string `json:"visibility,omitempty"` + Repositories []int `json:"selected_repository_ids,omitempty"` + KeyID string `json:"key_id"` +} + +type PubKey struct { + Raw [32]byte + ID string `json:"key_id"` + Key string +} + +func getPubKey(client *api.Client, host, path string) (*PubKey, error) { + pk := PubKey{} + err := client.REST(host, "GET", path, nil, &pk) + if err != nil { + return nil, err + } + + if pk.Key == "" { + return nil, fmt.Errorf("failed to find public key at %s/%s", host, path) + } + + decoded, err := base64.StdEncoding.DecodeString(pk.Key) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + + copy(pk.Raw[:], decoded[0:32]) + return &pk, nil +} + +func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) { + return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) +} + +func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) { + return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key", + ghrepo.FullName(repo))) +} + +func putSecret(client *api.Client, host, path string, payload SecretPayload) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + requestBody := bytes.NewReader(payloadBytes) + + return client.REST(host, "PUT", path, requestBody, nil) +} + +func putOrgSecret(client *api.Client, pk *PubKey, host string, opts CreateOptions, eValue string) error { + secretName := opts.SecretName + orgName := opts.OrgName + visibility := opts.Visibility + + var repositoryIDs []int + var err error + if orgName != "" && visibility == shared.VisSelected { + repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames) + if err != nil { + return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err) + } + } + + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + Repositories: repositoryIDs, + Visibility: visibility, + } + path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName) + + return putSecret(client, host, path, payload) +} + +func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error { + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + } + path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName) + return putSecret(client, repo.RepoHost(), path, payload) +} + +func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) { + queries := make([]string, 0, len(repositoryNames)) + for _, repoName := range repositoryNames { + queries = append(queries, fmt.Sprintf(` + %s: repository(owner: %q, name :%q) { + databaseId + } + `, repoName, orgName, repoName)) + } + + query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, "")) + + graphqlResult := make(map[string]*struct { + DatabaseID int `json:"databaseId"` + }) + + err := client.GraphQL(host, query, nil, &graphqlResult) + + gqlErr, isGqlErr := err.(*api.GraphQLErrorResponse) + if isGqlErr { + for _, ge := range gqlErr.Errors { + if ge.Type == "NOT_FOUND" { + return nil, fmt.Errorf("could not find %s/%s", orgName, ge.Path[0]) + } + } + } + if err != nil { + return nil, fmt.Errorf("failed to look up repositories: %w", err) + } + + result := make([]int, 0, len(repositoryNames)) + + for _, repoName := range repositoryNames { + result = append(result, graphqlResult[repoName].DatabaseID) + } + + return result, nil +} diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go new file mode 100644 index 000000000..b5eb85dc5 --- /dev/null +++ b/pkg/cmd/secret/list/list.go @@ -0,0 +1,156 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + OrgName string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List secrets", + Long: "List secrets for a repository or organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") + cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + + return cmd +} + +func listRun(opts *ListOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + var baseRepo ghrepo.Interface + if opts.OrgName == "" || opts.OrgName == "@owner" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + orgName := opts.OrgName + host := ghinstance.OverridableDefault() + if orgName == "@owner" { + orgName = baseRepo.RepoOwner() + host = baseRepo.RepoHost() + } + + var secrets []Secret + if orgName != "" { + secrets, err = getOrgSecrets(client, host, orgName) + } else { + secrets, err = getRepoSecrets(client, baseRepo) + } + + if err != nil { + return fmt.Errorf("failed to get secrets: %w", err) + } + + tp := utils.NewTablePrinter(opts.IO) + for _, secret := range secrets { + tp.AddField(secret.Name, nil, nil) + updatedAt := secret.UpdatedAt.Format("2006-01-02") + if opts.IO.IsStdoutTTY() { + updatedAt = fmt.Sprintf("Updated %s", updatedAt) + } + tp.AddField(updatedAt, nil, nil) + if secret.Visibility != "" { + if opts.IO.IsStdoutTTY() { + tp.AddField(fmtVisibility(secret), nil, nil) + } else { + tp.AddField(strings.ToUpper(secret.Visibility), nil, nil) + } + } + tp.EndRow() + } + + err = tp.Render() + if err != nil { + return err + } + + return nil +} + +type Secret struct { + Name string + UpdatedAt time.Time `json:"updated_at"` + Visibility string +} + +func fmtVisibility(s Secret) string { + switch s.Visibility { + case shared.VisAll: + return "Visible to all repositories" + case shared.VisPrivate: + return "Visible to private repositories" + case shared.VisSelected: + // TODO print how many? print which ones? + return "Visible to selected repositories" + } + return "" +} + +func getOrgSecrets(client *api.Client, host, orgName string) ([]Secret, error) { + return getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) +} + +func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]Secret, error) { + return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets", + ghrepo.FullName(repo))) +} + +type secretsPayload struct { + Secrets []Secret +} + +func getSecrets(client *api.Client, host, path string) ([]Secret, error) { + result := secretsPayload{} + + err := client.REST(host, "GET", path, nil, &result) + if err != nil { + return nil, err + } + + return result.Secrets, nil +} diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go new file mode 100644 index 000000000..8a7077de1 --- /dev/null +++ b/pkg/cmd/secret/list/list_test.go @@ -0,0 +1,227 @@ +package list + +import ( + "bytes" + "fmt" + "net/http" + "testing" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + }{ + { + name: "repo", + cli: "", + wants: ListOptions{ + OrgName: "", + }, + }, + { + name: "implicit org", + cli: "--org", + wants: ListOptions{ + OrgName: "@owner", + }, + }, + { + name: "explicit org", + cli: "--org=UmbrellaCorporation", + wants: ListOptions{ + OrgName: "UmbrellaCorporation", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + + }) + } +} + +// TODO run tests + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *ListOptions + wantOut []string + }{ + { + name: "repo tty", + tty: true, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11", + "SECRET_TWO.*Updated 2020-12-04", + "SECRET_THREE.*Updated 1975-11-30", + }, + }, + { + name: "repo not tty", + tty: false, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE\t1988-10-11", + "SECRET_TWO\t2020-12-04", + "SECRET_THREE\t1975-11-30", + }, + }, + { + name: "explicit org tty", + tty: true, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", + }, + }, + { + name: "explicit org not tty", + tty: false, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, + { + name: "implicit org not tty", + tty: false, + opts: &ListOptions{ + OrgName: "@owner", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, + { + name: "implicit org not tty", + tty: true, + opts: &ListOptions{ + OrgName: "@owner", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + t0, _ := time.Parse("2006-01-02", "1988-10-11") + t1, _ := time.Parse("2006-01-02", "2020-12-04") + t2, _ := time.Parse("2006-01-02", "1975-11-30") + path := "repos/owner/repo/actions/secrets" + payload := secretsPayload{} + payload.Secrets = []Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + }, + } + if tt.opts.OrgName != "" { + payload.Secrets = []Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + Visibility: shared.VisAll, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + Visibility: shared.VisPrivate, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + Visibility: shared.VisSelected, + }, + } + if tt.opts.OrgName == "@owner" { + path = "orgs/owner/actions/secrets" + } else { + path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) + } + } + + reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) + + io, _, stdout, _ := iostreams.Test() + + io.SetStdoutTTY(tt.tty) + + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + err := listRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + test.ExpectLines(t, stdout.String(), tt.wantOut...) + }) + } +} diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go new file mode 100644 index 000000000..4932b6407 --- /dev/null +++ b/pkg/cmd/secret/secret.go @@ -0,0 +1,28 @@ +package secret + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" + + cmdCreate "github.com/cli/cli/pkg/cmd/secret/create" + cmdList "github.com/cli/cli/pkg/cmd/secret/list" +) + +func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret ", + Short: "Manage GitHub secrets", + Long: heredoc.Doc(` + Secrets can be set at the repository or organization level for use in GitHub Actions. + Run "gh help secret add" to learn how to get started. +`), + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + + return cmd +} diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go new file mode 100644 index 000000000..7e18b1298 --- /dev/null +++ b/pkg/cmd/secret/shared/shared.go @@ -0,0 +1,7 @@ +package shared + +const ( + VisAll = "all" + VisPrivate = "private" + VisSelected = "selected" +) From 03949a4d72bf1f39887126c040dcf5ef2bf295bd Mon Sep 17 00:00:00 2001 From: Pete Woods Date: Mon, 7 Dec 2020 12:53:32 +0000 Subject: [PATCH 056/129] Build static binaries Fixes #2555 --- .github/workflows/go.yml | 2 ++ .goreleaser.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 83fa87b8e..1d9613f04 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -39,4 +39,6 @@ jobs: uses: actions/checkout@v2 - name: Build + env: + CGO_ENABLED: '0' run: go build -v ./cmd/gh diff --git a/.goreleaser.yml b/.goreleaser.yml index f04e7f7f2..3146b9c6f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -25,6 +25,8 @@ builds: id: linux goos: [linux] goarch: [386, amd64, arm64] + env: + - CGO_ENABLED=0 - <<: *build_defaults id: windows From c39dc28fa1cdbbd4b429d2a65a8aa179efaffd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 17:07:45 +0100 Subject: [PATCH 057/129] Rename `auth/client` to `auth/shared` --- pkg/cmd/auth/login/login.go | 12 ++++++------ pkg/cmd/auth/login/login_test.go | 14 +++++++------- pkg/cmd/auth/{client => shared}/client.go | 2 +- pkg/cmd/auth/status/status_test.go | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) rename pkg/cmd/auth/{client => shared}/client.go (98%) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index f92e6e7f3..d923870ed 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -16,7 +16,7 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" @@ -138,7 +138,7 @@ func loginRun(opts *LoginOptions) error { return err } - err = client.ValidateHostCfg(opts.Hostname, cfg) + err = shared.ValidateHostCfg(opts.Hostname, cfg) if err != nil { return err } @@ -182,9 +182,9 @@ func loginRun(opts *LoginOptions) error { existingToken, _ := cfg.Get(hostname, "oauth_token") if existingToken != "" && opts.Interactive { - err := client.ValidateHostCfg(hostname, cfg) + err := shared.ValidateHostCfg(hostname, cfg) if err == nil { - apiClient, err := client.ClientFromCfg(hostname, cfg) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } @@ -258,7 +258,7 @@ func loginRun(opts *LoginOptions) error { return err } - err = client.ValidateHostCfg(hostname, cfg) + err = shared.ValidateHostCfg(hostname, cfg) if err != nil { return err } @@ -294,7 +294,7 @@ func loginRun(opts *LoginOptions) error { if userValidated { username, _ = cfg.Get(hostname, "user") } else { - apiClient, err := client.ClientFromCfg(hostname, cfg) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index c41626939..1c33dfcc2 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -8,7 +8,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -262,11 +262,11 @@ func Test_loginRun_nontty(t *testing.T) { tt.opts.IO = io t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { httpClient := &http.Client{Transport: reg} return api.NewClientFromHTTP(httpClient), nil } @@ -429,11 +429,11 @@ func Test_loginRun_Survey(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { httpClient := &http.Client{Transport: reg} return api.NewClientFromHTTP(httpClient), nil } diff --git a/pkg/cmd/auth/client/client.go b/pkg/cmd/auth/shared/client.go similarity index 98% rename from pkg/cmd/auth/client/client.go rename to pkg/cmd/auth/shared/client.go index bacde0b82..217254237 100644 --- a/pkg/cmd/auth/client/client.go +++ b/pkg/cmd/auth/shared/client.go @@ -1,4 +1,4 @@ -package client +package shared import ( "fmt" diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 0de14d388..6974770dc 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -8,7 +8,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -217,11 +217,11 @@ func Test_statusRun(t *testing.T) { } reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { httpClient := &http.Client{Transport: reg} return api.NewClientFromHTTP(httpClient), nil } From 381e83e6e5d3c4995256f25dc053d121f98854f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 20:01:16 +0100 Subject: [PATCH 058/129] Extend git credential prompt to `auth refresh` --- pkg/cmd/auth/login/login.go | 91 +---------------- pkg/cmd/auth/refresh/refresh.go | 27 +++-- pkg/cmd/auth/shared/git_credential.go | 110 +++++++++++++++++++++ pkg/cmd/auth/shared/git_credential_test.go | 88 +++++++++++++++++ 4 files changed, 219 insertions(+), 97 deletions(-) create mode 100644 pkg/cmd/auth/shared/git_credential.go create mode 100644 pkg/cmd/auth/shared/git_credential_test.go diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index d923870ed..c4aed6c20 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -1,26 +1,21 @@ package login import ( - "bytes" "errors" "fmt" "io/ioutil" - "path/filepath" "strings" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/git" "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" - "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -316,63 +311,9 @@ func loginRun(opts *LoginOptions) error { } if opts.Interactive && gitProtocol == "https" { - helper, _ := gitCredentialHelper(hostname) - if !isOurCredentialHelper(helper) { - var primeCredentials bool - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Set up git for passwordless push/pull operations?", - Default: true, - }, &primeCredentials) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - - if primeCredentials { - if helper == "" { - configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential") - if err != nil { - return err - } - - err = run.PrepareCmd(configureCmd).Run() - if err != nil { - return err - } - } else { - rejectCmd, err := git.GitCommand("credential", "reject") - if err != nil { - return err - } - - rejectCmd.Stdin = bytes.NewBufferString(fmt.Sprintf(heredoc.Doc(` - protocol=https - host=%s - `), hostname)) - - err = run.PrepareCmd(rejectCmd).Run() - if err != nil { - return err - } - - approveCmd, err := git.GitCommand("credential", "approve") - if err != nil { - return err - } - - password, _ := cfg.Get(hostname, "oauth_token") - approveCmd.Stdin = bytes.NewBufferString(fmt.Sprintf(heredoc.Doc(` - protocol=https - host=%s - username=%s - password=%s - `), hostname, username, password)) - - err = run.PrepareCmd(approveCmd).Run() - if err != nil { - return err - } - } - } + err := shared.GitCredentialSetup(cfg, hostname, username) + if err != nil { + return err } } @@ -390,29 +331,3 @@ func getAccessTokenTip(hostname string) string { Tip: you can generate a Personal Access Token here https://%s/settings/tokens The minimum required scopes are 'repo' and 'read:org'.`, ghHostname) } - -func gitCredentialHelperKey(hostname string) string { - return fmt.Sprintf("credential.https://%s.helper", hostname) -} - -func gitCredentialHelper(hostname string) (helper string, err error) { - helper, err = git.Config(gitCredentialHelperKey(hostname)) - if helper != "" { - return - } - helper, err = git.Config("credential.helper") - return -} - -func isOurCredentialHelper(cmd string) bool { - if !strings.HasPrefix(cmd, "!") { - return false - } - - args, err := shlex.Split(cmd[1:]) - if err != nil || len(args) == 0 { - return false - } - - return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh" -} diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index dd7862f3f..621b717cc 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" @@ -21,6 +22,8 @@ type RefreshOptions struct { Hostname string Scopes []string AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error + + Interactive bool } func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { @@ -50,21 +53,15 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. # => open a browser to ensure your authentication credentials have the correct minimum scopes `), RunE: func(cmd *cobra.Command, args []string) error { - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + opts.Interactive = opts.IO.CanPrompt() - if !isTTY { - return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") - } - - if opts.Hostname == "" && !opts.IO.CanPrompt() { - // here, we know we are attached to a TTY but prompts are disabled + if !opts.Interactive && opts.Hostname == "" { return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} } if runF != nil { return runF(opts) } - return refreshRun(opts) }, } @@ -118,5 +115,17 @@ func refreshRun(opts *RefreshOptions) error { return err } - return opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes) + if err := opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes); err != nil { + return err + } + + protocol, _ := cfg.Get(hostname, "git_protocol") + if opts.Interactive && protocol == "https" { + username, _ := cfg.Get(hostname, "user") + if err := shared.GitCredentialSetup(cfg, hostname, username); err != nil { + return err + } + } + + return nil } diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go new file mode 100644 index 000000000..bc0b3de1e --- /dev/null +++ b/pkg/cmd/auth/shared/git_credential.go @@ -0,0 +1,110 @@ +package shared + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" +) + +type configReader interface { + Get(string, string) (string, error) +} + +func GitCredentialSetup(cfg configReader, hostname, username string) error { + helper, _ := gitCredentialHelper(hostname) + if isOurCredentialHelper(helper) { + return nil + } + + var primeCredentials bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: "Authenticate Git with your GitHub credentials?", + Default: true, + }, &primeCredentials) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !primeCredentials { + return nil + } + + if helper == "" { + // use GitHub CLI as a credential helper (for this host only) + configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential") + if err != nil { + return err + } + return run.PrepareCmd(configureCmd).Run() + } + + // clear previous cached credentials + rejectCmd, err := git.GitCommand("credential", "reject") + if err != nil { + return err + } + + rejectCmd.Stdin = bytes.NewBufferString(heredoc.Docf(` + protocol=https + host=%s + `, hostname)) + + err = run.PrepareCmd(rejectCmd).Run() + if err != nil { + return err + } + + approveCmd, err := git.GitCommand("credential", "approve") + if err != nil { + return err + } + + password, _ := cfg.Get(hostname, "oauth_token") + approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(` + protocol=https + host=%s + username=%s + password=%s + `, hostname, username, password)) + + err = run.PrepareCmd(approveCmd).Run() + if err != nil { + return err + } + + return nil +} + +func gitCredentialHelperKey(hostname string) string { + return fmt.Sprintf("credential.https://%s.helper", hostname) +} + +func gitCredentialHelper(hostname string) (helper string, err error) { + helper, err = git.Config(gitCredentialHelperKey(hostname)) + if helper != "" { + return + } + helper, err = git.Config("credential.helper") + return +} + +func isOurCredentialHelper(cmd string) bool { + if !strings.HasPrefix(cmd, "!") { + return false + } + + args, err := shlex.Split(cmd[1:]) + if err != nil || len(args) == 0 { + return false + } + + return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh" +} diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go new file mode 100644 index 000000000..a8d2fe408 --- /dev/null +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -0,0 +1,88 @@ +package shared + +import ( + "fmt" + "testing" + + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/prompt" +) + +type tinyConfig map[string]string + +func (c tinyConfig) Get(host, key string) (string, error) { + return c[fmt.Sprintf("%s:%s", host, key)], nil +} + +func TestGitCredentialSetup_configureExisting(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 0, "osxkeychain\n") + cs.Register(`git credential reject`, 0, "") + cs.Register(`git credential approve`, 0, "") + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(true) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_setOurs(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 1, "") + cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) { + if val := args[len(args)-1]; val != "!gh auth git-credential" { + t.Errorf("global credential helper configured to %q", val) + } + }) + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(true) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_promptDeny(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 1, "") + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(false) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_isOurs(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n") + + _, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} From 3c76eb15a45fc63206664d364d115791834fb9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 20:01:53 +0100 Subject: [PATCH 059/129] Add tests for `auth git-credential` command --- pkg/cmd/auth/gitcredential/helper.go | 41 +++--- pkg/cmd/auth/gitcredential/helper_test.go | 154 ++++++++++++++++++++++ 2 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 pkg/cmd/auth/gitcredential/helper_test.go diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index a9dbcbad9..c62cef3a8 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -6,23 +6,28 @@ import ( "net/url" "strings" - "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/spf13/cobra" ) +type config interface { + Get(string, string) (string, error) +} + type CredentialOptions struct { IO *iostreams.IOStreams - Config func() (config.Config, error) + Config func() (config, error) Operation string } func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command { opts := &CredentialOptions{ - IO: f.IOStreams, - Config: f.Config, + IO: f.IOStreams, + Config: func() (config, error) { + return f.Config() + }, } cmd := &cobra.Command{ @@ -36,7 +41,6 @@ func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) * if runF != nil { return runF(opts) } - return helperRun(opts) }, } @@ -66,24 +70,25 @@ func helperRun(opts *CredentialOptions) error { if len(parts) < 2 { continue } - wants[parts[0]] = parts[1] + key, value := parts[0], parts[1] + if key == "url" { + u, err := url.Parse(value) + if err != nil { + return err + } + wants["protocol"] = u.Scheme + wants["host"] = u.Host + wants["path"] = u.Path + wants["username"] = u.User.Username() + wants["password"], _ = u.User.Password() + } else { + wants[key] = value + } } if err := s.Err(); err != nil { return err } - if uv := wants["url"]; uv != "" { - u, err := url.Parse(uv) - if err != nil { - return err - } - wants["protocol"] = u.Scheme - wants["host"] = u.Host - wants["path"] = u.Path - wants["username"] = u.User.Username() - wants["password"], _ = u.User.Password() - } - if wants["protocol"] != "https" { return cmdutil.SilentError } diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go new file mode 100644 index 000000000..336d4ef34 --- /dev/null +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -0,0 +1,154 @@ +package login + +import ( + "fmt" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/iostreams" +) + +type tinyConfig map[string]string + +func (c tinyConfig) Get(host, key string) (string, error) { + return c[fmt.Sprintf("%s:%s", host, key)], nil +} + +func Test_helperRun(t *testing.T) { + tests := []struct { + name string + opts CredentialOptions + input string + wantStdout string + wantStderr string + wantErr bool + }{ + { + name: "host only, credentials found", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "host plus user", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "url input", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + url=https://monalisa@example.com + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "host only, no credentials found", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + `), + wantErr: true, + wantStdout: "", + wantStderr: "", + }, + { + name: "user mismatch", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + username=hubot + `), + wantErr: true, + wantStdout: "", + wantStderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, stdout, stderr := iostreams.Test() + fmt.Fprint(stdin, tt.input) + opts := &tt.opts + opts.IO = io + if err := helperRun(opts); (err != nil) != tt.wantErr { + t.Fatalf("helperRun() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantStdout != stdout.String() { + t.Errorf("stdout: got %q, wants %q", stdout.String(), tt.wantStdout) + } + if tt.wantStderr != stderr.String() { + t.Errorf("stderr: got %q, wants %q", stderr.String(), tt.wantStderr) + } + }) + } +} From 38ea595ce270c768a65fc2ab31326ac703f31e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 20:07:20 +0100 Subject: [PATCH 060/129] Fix `refresh` test --- pkg/cmd/auth/refresh/refresh_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 065dd3fa2..42bb2f202 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -37,9 +37,11 @@ func Test_NewCmdRefresh(t *testing.T) { wantsErr: true, }, { - name: "nontty hostname", - cli: "-h aline.cedrac", - wantsErr: true, + name: "nontty hostname", + cli: "-h aline.cedrac", + wants: RefreshOptions{ + Hostname: "aline.cedrac", + }, }, { name: "tty hostname", From ada59236c6b18feba6f3bffb0d18edfa6beeeb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 20:12:58 +0100 Subject: [PATCH 061/129] Add `workflow` to the list of default OAuth scopes we request Since GitHub CLI now offers to authenticate your Git as well, the token we request here will be used for git pushes. Since we do anticipate our users making edits to their GitHub Actions workflow files, we want them to be able to push their changes, and this scope allows that. --- internal/authflow/flow.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 896895d21..65c17fd0b 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -63,7 +63,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition verboseStream = w } - minimumScopes := []string{"repo", "read:org", "gist"} + minimumScopes := []string{"repo", "read:org", "gist", "workflow"} scopes := append(minimumScopes, additionalScopes...) flow := &auth.OAuthFlow{ From c843a4fa13a206db8331b52f93305c6a19b955ab Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 13 Nov 2020 11:16:54 +0300 Subject: [PATCH 062/129] Add issue comment viewing --- api/queries_issue.go | 57 ++- api/reaction_groups.go | 52 +++ api/reaction_groups_test.go | 55 +++ pkg/cmd/issue/shared/lookup.go | 51 +-- .../view/fixtures/issueView_preview.json | 8 +- .../issueView_previewClosedState.json | 2 +- .../issueView_previewFullComments.json | 383 ++++++++++++++++++ .../issueView_previewSingleComment.json | 147 +++++++ .../issueView_previewThreeComments.json | 265 ++++++++++++ .../issueView_previewWithEmptyBody.json | 2 +- .../issueView_previewWithMetadata.json | 60 ++- pkg/cmd/issue/view/view.go | 138 ++++++- pkg/cmd/issue/view/view_test.go | 210 +++++++++- pkg/iostreams/color.go | 24 +- utils/utils.go | 16 + utils/utils_test.go | 27 +- 16 files changed, 1408 insertions(+), 89 deletions(-) create mode 100644 api/reaction_groups.go create mode 100644 api/reaction_groups_test.go create mode 100644 pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json create mode 100644 pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json create mode 100644 pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json diff --git a/api/queries_issue.go b/api/queries_issue.go index 08e0cf1d9..1aa83ad89 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -33,12 +33,8 @@ type Issue struct { Body string CreatedAt time.Time UpdatedAt time.Time - Comments struct { - TotalCount int - } - Author struct { - Login string - } + Comments IssueComments + Author Author Assignees struct { Nodes []struct { Login string @@ -65,12 +61,31 @@ type Issue struct { Milestone struct { Title string } + ReactionGroups ReactionGroups } type IssuesDisabledError struct { error } +type IssueComments struct { + Nodes []IssueComment + TotalCount int +} + +type IssueComment struct { + Author Author + AuthorAssociation string + Body string + CreatedAt time.Time + IncludesCreatedEdit bool + ReactionGroups ReactionGroups +} + +type Author struct { + Login string +} + const fragments = ` fragment issue on Issue { number @@ -320,7 +335,7 @@ loop: return &res, nil } -func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { +func IssueByNumber(client *Client, repo ghrepo.Interface, number, comments int) (*Issue, error) { type response struct { Repository struct { Issue Issue @@ -329,7 +344,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e } query := ` - query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) { + query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!, $comments: Int!) { repository(owner: $owner, name: $repo) { hasIssuesEnabled issue(number: $issue_number) { @@ -341,7 +356,22 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e author { login } - comments { + comments(last: $comments) { + nodes { + author { + login + } + authorAssociation + body + createdAt + includesCreatedEdit + reactionGroups { + content + users { + totalCount + } + } + } totalCount } number @@ -370,9 +400,15 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e } totalCount } - milestone{ + milestone { title } + reactionGroups { + content + users { + totalCount + } + } } } }` @@ -381,6 +417,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e "owner": repo.RepoOwner(), "repo": repo.RepoName(), "issue_number": number, + "comments": comments, } var resp response diff --git a/api/reaction_groups.go b/api/reaction_groups.go new file mode 100644 index 000000000..68c527079 --- /dev/null +++ b/api/reaction_groups.go @@ -0,0 +1,52 @@ +package api + +import ( + "fmt" + "strings" +) + +type ReactionGroups []ReactionGroup + +type ReactionGroup struct { + Content string + Users ReactionGroupUsers +} + +type ReactionGroupUsers struct { + TotalCount int +} + +func (rg ReactionGroup) String() string { + c := rg.Users.TotalCount + if c == 0 { + return "" + } + e := reactionEmoji[rg.Content] + if e == "" { + return "" + } + return fmt.Sprintf("%v %s", c, e) +} + +func (rgs ReactionGroups) String() string { + var rs []string + + for _, rg := range rgs { + if r := rg.String(); r != "" { + rs = append(rs, r) + } + } + + return strings.Join(rs, " • ") +} + +var reactionEmoji = map[string]string{ + "THUMBS_UP": "\U0001f44d", + "THUMBS_DOWN": "\U0001f44e", + "LAUGH": "\U0001f604", + "HOORAY": "\U0001f389", + "CONFUSED": "\U0001f615", + "HEART": "\u2764\ufe0f", + "ROCKET": "\U0001f680", + "EYES": "\U0001f440", +} diff --git a/api/reaction_groups_test.go b/api/reaction_groups_test.go new file mode 100644 index 000000000..f2fa7b1d4 --- /dev/null +++ b/api/reaction_groups_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_String(t *testing.T) { + tests := map[string]struct { + rgs ReactionGroups + output string + }{ + "empty reaction groups": { + rgs: []ReactionGroup{}, + output: `^$`, + }, + "non-empty reaction groups": { + rgs: []ReactionGroup{ + ReactionGroup{ + Content: "LAUGH", + Users: ReactionGroupUsers{TotalCount: 0}, + }, + ReactionGroup{ + Content: "HOORAY", + Users: ReactionGroupUsers{TotalCount: 1}, + }, + ReactionGroup{ + Content: "CONFUSED", + Users: ReactionGroupUsers{TotalCount: 0}, + }, + ReactionGroup{ + Content: "HEART", + Users: ReactionGroupUsers{TotalCount: 2}, + }, + }, + output: `^1 \x{1f389} • 2 \x{2764}\x{fe0f}$`, + }, + "reaction groups with unmapped emoji": { + rgs: []ReactionGroup{ + ReactionGroup{ + Content: "UNKNOWN", + Users: ReactionGroupUsers{TotalCount: 1}, + }, + }, + output: `^$`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + assert.Regexp(t, tt.output, tt.rgs.String()) + }) + } +} diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 90a729599..09be5bab6 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -11,52 +11,55 @@ import ( "github.com/cli/cli/internal/ghrepo" ) -func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { - issue, baseRepo, err := issueFromURL(apiClient, arg) - if err != nil { - return nil, nil, err - } - if issue != nil { - return issue, baseRepo, nil +func IssueWithCommentsFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, comments int) (*api.Issue, ghrepo.Interface, error) { + issueNumber, baseRepo := issueMetadataFromURL(arg) + + if baseRepo == nil { + var err error + baseRepo, err = baseRepoFn() + if err != nil { + return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + } } - baseRepo, err = baseRepoFn() - if err != nil { - return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + if issueNumber == 0 { + var err error + issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) + if err != nil { + return nil, nil, fmt.Errorf("invalid issue format: %q", arg) + } } - issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")) - if err != nil { - return nil, nil, fmt.Errorf("invalid issue format: %q", arg) - } - - issue, err = issueFromNumber(apiClient, baseRepo, issueNumber) + issue, err := issueFromNumber(apiClient, baseRepo, issueNumber, comments) return issue, baseRepo, err } +func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { + return IssueWithCommentsFromArg(apiClient, baseRepoFn, arg, 0) +} + var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`) -func issueFromURL(apiClient *api.Client, s string) (*api.Issue, ghrepo.Interface, error) { +func issueMetadataFromURL(s string) (int, ghrepo.Interface) { u, err := url.Parse(s) if err != nil { - return nil, nil, nil + return 0, nil } if u.Scheme != "https" && u.Scheme != "http" { - return nil, nil, nil + return 0, nil } m := issueURLRE.FindStringSubmatch(u.Path) if m == nil { - return nil, nil, nil + return 0, nil } repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) issueNumber, _ := strconv.Atoi(m[3]) - issue, err := issueFromNumber(apiClient, repo, issueNumber) - return issue, repo, err + return issueNumber, repo } -func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber int) (*api.Issue, error) { - return api.IssueByNumber(apiClient, repo, issueNumber) +func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber, comments int) (*api.Issue, error) { + return api.IssueByNumber(apiClient, repo, issueNumber, comments) } diff --git a/pkg/cmd/issue/view/fixtures/issueView_preview.json b/pkg/cmd/issue/view/fixtures/issueView_preview.json index e25090a61..65fc5ef51 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_preview.json +++ b/pkg/cmd/issue/view/fixtures/issueView_preview.json @@ -7,21 +7,21 @@ "body": "**bold story**", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, "assignees": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "labels": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "projectcards": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "milestone": { "title": "" diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json index 978927125..4665c47e1 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json @@ -7,7 +7,7 @@ "body": "**bold story**", "title": "ix of coins", "state": "CLOSED", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json new file mode 100644 index 000000000..2805c9694 --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json @@ -0,0 +1,383 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issue": { + "number": 123, + "body": "some body", + "title": "some title", + "state": "OPEN", + "createdAt": "2020-01-01T12:00:00Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "projectcards": { + "nodes": [], + "totalCount": 0 + }, + "milestone": { + "title": "" + }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "monalisa" + }, + "authorAssociation": "NONE", + "body": "Comment 1", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 7 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 8 + } + } + ] + }, + { + "author": { + "login": "johnnytest" + }, + "authorAssociation": "CONTRIBUTOR", + "body": "Comment 2", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "elvisp" + }, + "authorAssociation": "MEMBER", + "body": "Comment 3", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "loislane" + }, + "authorAssociation": "OWNER", + "body": "Comment 4", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + }, + "url": "https://github.com/OWNER/REPO/issues/123" + } + } + } +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json new file mode 100644 index 000000000..87fc7bffc --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json @@ -0,0 +1,147 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issue": { + "number": 123, + "body": "some body", + "title": "some title", + "state": "OPEN", + "createdAt": "2020-01-01T12:00:00Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "projectcards": { + "nodes": [], + "totalCount": 0 + }, + "milestone": { + "title": "" + }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + }, + "url": "https://github.com/OWNER/REPO/issues/123" + } + } + } +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json new file mode 100644 index 000000000..74d5945e1 --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json @@ -0,0 +1,265 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issue": { + "number": 123, + "body": "some body", + "title": "some title", + "state": "OPEN", + "createdAt": "2020-01-01T12:00:00Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "projectcards": { + "nodes": [], + "totalCount": 0 + }, + "milestone": { + "title": "" + }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "elvisp" + }, + "authorAssociation": "MEMBER", + "body": "Comment 3", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "loislane" + }, + "authorAssociation": "OWNER", + "body": "Comment 4", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + }, + "url": "https://github.com/OWNER/REPO/issues/123" + } + } + } +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json index 6e87a42b6..104f134be 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json @@ -7,7 +7,7 @@ "body": "", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json index 246bbd77b..9bce5f745 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json @@ -7,7 +7,7 @@ "body": "**bold story**", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, @@ -20,7 +20,7 @@ "login": "monaco" } ], - "totalcount": 2 + "totalCount": 2 }, "labels": { "nodes": [ @@ -40,7 +40,7 @@ "name": "five" } ], - "totalcount": 5 + "totalCount": 5 }, "projectcards": { "nodes": [ @@ -77,13 +77,63 @@ } } ], - "totalcount": 3 + "totalCount": 3 }, "milestone": { "title": "uluru" }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 8 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 7 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 6 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 5 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 4 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 3 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 2 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 1 + } + } + ], "comments": { - "totalcount": 9 + "totalCount": 9 }, "url": "https://github.com/OWNER/REPO/issues/123" } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 2bada530c..759789036 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -29,6 +29,7 @@ type ViewOptions struct { SelectorArg string WebMode bool + Comments int } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -65,6 +66,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") + cmd.Flags().IntVarP(&opts.Comments, "comments", "c", 1, "View issue comments") + cmd.Flags().Lookup("comments").NoOptDefVal = "30" return cmd } @@ -76,7 +79,7 @@ func viewRun(opts *ViewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + issue, _, err := issueShared.IssueWithCommentsFromArg(apiClient, opts.BaseRepo, opts.SelectorArg, opts.Comments) if err != nil { return err } @@ -122,9 +125,33 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { fmt.Fprintln(out, "--") fmt.Fprintln(out, issue.Body) + fmt.Fprintln(out, "--") + + if len(issue.Comments.Nodes) > 0 { + fmt.Fprintf(out, rawIssueComments(issue.Comments)) + } + return nil } +func rawIssueComments(comments api.IssueComments) string { + var b strings.Builder + for _, comment := range comments.Nodes { + fmt.Fprintf(&b, rawIssueComment(comment)) + } + return b.String() +} + +func rawIssueComment(comment api.IssueComment) string { + var b strings.Builder + fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) + fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) + fmt.Fprintln(&b, "--") + fmt.Fprintln(&b, comment.Body) + fmt.Fprintln(&b, "--") + return b.String() +} + func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { out := io.Out now := time.Now() @@ -133,16 +160,21 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { // Header (Title and State) fmt.Fprintln(out, cs.Bold(issue.Title)) - fmt.Fprint(out, issueStateTitleWithColor(cs, issue.State)) - fmt.Fprintln(out, cs.Gray(fmt.Sprintf( - " • %s opened %s • %s", + fmt.Fprintln(out, fmt.Sprintf( + "%s • %s opened %s • %s", + issueStateTitleWithColor(cs, issue.State), issue.Author.Login, utils.FuzzyAgo(ago), utils.Pluralize(issue.Comments.TotalCount, "comment"), - ))) + )) + + // Reactions + if reactions := issue.ReactionGroups.String(); reactions != "" { + fmt.Fprint(out, reactions) + fmt.Fprintln(out) + } // Metadata - fmt.Fprintln(out) if assignees := issueAssigneeList(*issue); assignees != "" { fmt.Fprint(out, cs.Bold("Assignees: ")) fmt.Fprintln(out, assignees) @@ -161,22 +193,102 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { } // Body - if issue.Body != "" { - fmt.Fprintln(out) - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(issue.Body, style, "") + fmt.Fprintln(out) + if issue.Body == "" { + issue.Body = "_No description provided_" + } + style := markdown.GetStyle(io.TerminalTheme()) + md, err := markdown.Render(issue.Body, style, "") + if err != nil { + return err + } + fmt.Fprint(out, md) + fmt.Fprintln(out) + + // Comments + if issue.Comments.TotalCount > 0 { + comments, err := issueComments(io, issue.Comments) if err != nil { return err } - fmt.Fprintln(out, md) + fmt.Fprintf(out, comments) } - fmt.Fprintln(out) // Footer - fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) + fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s"), issue.URL) + return nil } +func issueComments(io *iostreams.IOStreams, comments api.IssueComments) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + retrievedCount := len(comments.Nodes) + hiddenCount := comments.TotalCount - retrievedCount + + if hiddenCount > 0 { + fmt.Fprintf(&b, cs.Gray(fmt.Sprintf("———————— Hiding %v comments ————————", hiddenCount))) + fmt.Fprintf(&b, "\n\n\n") + } + + for i, comment := range comments.Nodes { + last := i+1 == retrievedCount + cmt, err := issueComment(io, comment, last) + if err != nil { + return "", err + } + fmt.Fprintf(&b, cmt) + if last { + fmt.Fprintln(&b) + } + } + + if hiddenCount > 0 { + fmt.Fprintf(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprintln(&b) + } + + return b.String(), nil +} + +func issueComment(io *iostreams.IOStreams, comment api.IssueComment, newest bool) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + + // Header + fmt.Fprintf(&b, cs.Bold(comment.Author.Login)) + if comment.AuthorAssociation != "NONE" { + fmt.Fprintf(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) + } + fmt.Fprintf(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) + if comment.IncludesCreatedEdit { + fmt.Fprintf(&b, cs.Bold(" • edited")) + } + if newest { + fmt.Fprintf(&b, cs.Bold(" • ")) + fmt.Fprintf(&b, cs.CyanBold("Newest comment")) + } + fmt.Fprintln(&b) + + // Reactions + if reactions := comment.ReactionGroups.String(); reactions != "" { + fmt.Fprint(&b, reactions) + fmt.Fprintln(&b) + } + + // Body + if comment.Body != "" { + style := markdown.GetStyle(io.TerminalTheme()) + md, err := markdown.Render(comment.Body, style, "") + if err != nil { + return "", err + } + fmt.Fprint(&b, md) + } + + return b.String(), nil +} + func issueStateTitleWithColor(cs *iostreams.ColorScheme, state string) string { colorFunc := cs.ColorFromString(prShared.ColorForState(state)) return colorFunc(strings.Title(strings.ToLower(state))) diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 0567c87ff..cb1b1604e 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "testing" "github.com/cli/cli/internal/config" @@ -16,15 +15,9 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -86,14 +79,14 @@ func TestIssueView_web(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) } func TestIssueView_web_numberArgWithHash(t *testing.T) { @@ -119,14 +112,14 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) } func TestIssueView_nontty_Preview(t *testing.T) { @@ -192,7 +185,7 @@ func TestIssueView_nontty_Preview(t *testing.T) { t.Errorf("error running `issue view`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -208,7 +201,7 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_preview.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, @@ -217,7 +210,8 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewWithMetadata.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, + `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`, `Assignees:.*marseilles, monaco\n`, `Labels:.*one, two, three, four, five\n`, `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, @@ -230,7 +224,8 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewWithEmptyBody.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, + `No description provided`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, @@ -238,7 +233,7 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewClosedState.json", expectedOutputs: []string{ `ix of coins`, - `Closed.*marseilles opened about 292 years ago.*9 comments`, + `Closed.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, @@ -256,7 +251,7 @@ func TestIssueView_tty_Preview(t *testing.T) { t.Errorf("error running `issue view`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -330,11 +325,182 @@ func TestIssueView_web_urlArg(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) +} + +func TestIssueView_tty_Comments(t *testing.T) { + tests := map[string]struct { + cli string + fixture string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + cli: "123", + fixture: "./fixtures/issueView_previewSingleComment.json", + expectedOutputs: []string{ + `some title`, + `some body`, + `———————— Hiding 4 comments ————————`, + `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `Use --comments to view the full conversation`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "with default comments flag": { + cli: "123 --comments", + fixture: "./fixtures/issueView_previewFullComments.json", + expectedOutputs: []string{ + `some title`, + `some body`, + `monalisa • Jan 1, 2020`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, + `Comment 1`, + `johnnytest \(contributor\) • Jan 1, 2020`, + `Comment 2`, + `elvisp \(member\) • Jan 1, 2020`, + `Comment 3`, + `loislane \(owner\) • Jan 1, 2020`, + `Comment 4`, + `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "with specified comments flag": { + cli: "123 --comments=3", + fixture: "./fixtures/issueView_previewThreeComments.json", + expectedOutputs: []string{ + `some title`, + `some body`, + `———————— Hiding 2 comments ————————`, + `elvisp \(member\) • Jan 1, 2020`, + `Comment 3`, + `loislane \(owner\) • Jan 1, 2020`, + `Comment 4`, + `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `Use --comments to view the full conversation`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "with incorrect comments flag": { + cli: "123 --comments 3", + fixture: "", + wantsErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + if tc.fixture != "" { + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + } + output, err := runCommand(http, true, tc.cli) + if tc.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_nontty_Comments(t *testing.T) { + tests := map[string]struct { + cli string + fixture string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + cli: "123", + fixture: "./fixtures/issueView_previewSingleComment.json", + expectedOutputs: []string{ + `title:\tsome title`, + `author:\tmarseilles`, + `comments:\t5`, + `some body`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `Comment 5`, + }, + }, + "with default comments flag": { + cli: "123 --comments", + fixture: "./fixtures/issueView_previewFullComments.json", + expectedOutputs: []string{ + `title:\tsome title`, + `author:\tmarseilles`, + `comments:\t5`, + `some body`, + `author:\tmonalisa`, + `association:\t`, + `Comment 1`, + `author:\tjohnnytest`, + `association:\tcontributor`, + `Comment 2`, + `author:\telvisp`, + `association:\tmember`, + `Comment 3`, + `author:\tloislane`, + `association:\towner`, + `Comment 4`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `Comment 5`, + }, + }, + "with specified comments flag": { + cli: "123 --comments=3", + fixture: "./fixtures/issueView_previewThreeComments.json", + expectedOutputs: []string{ + `title:\tsome title`, + `author:\tmarseilles`, + `comments:\t5`, + `some body`, + `author:\telvisp`, + `association:\tmember`, + `Comment 3`, + `author:\tloislane`, + `association:\towner`, + `Comment 4`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `Comment 5`, + }, + }, + "with incorrect comments flag": { + cli: "123 --comments 3", + fixture: "", + wantsErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + if tc.fixture != "" { + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + } + output, err := runCommand(http, false, tc.cli) + if tc.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 6fc2bd023..972caab3d 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -9,14 +9,15 @@ import ( ) var ( - magenta = ansi.ColorFunc("magenta") - cyan = ansi.ColorFunc("cyan") - red = ansi.ColorFunc("red") - yellow = ansi.ColorFunc("yellow") - blue = ansi.ColorFunc("blue") - green = ansi.ColorFunc("green") - gray = ansi.ColorFunc("black+h") - bold = ansi.ColorFunc("default+b") + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + green = ansi.ColorFunc("green") + gray = ansi.ColorFunc("black+h") + bold = ansi.ColorFunc("default+b") + cyanBold = ansi.ColorFunc("cyan+b") gray256 = func(t string) string { return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) @@ -107,6 +108,13 @@ func (c *ColorScheme) Cyan(t string) string { return cyan(t) } +func (c *ColorScheme) CyanBold(t string) string { + if !c.enabled { + return t + } + return cyanBold(t) +} + func (c *ColorScheme) Blue(t string) string { if !c.enabled { return t diff --git a/utils/utils.go b/utils/utils.go index 602b8ab1e..4b58508ef 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -59,6 +59,22 @@ func FuzzyAgo(ago time.Duration) string { return fmtDuration(int(ago.Hours()/24/365), "year") } +func FuzzyAgoAbbr(now time.Time, createdAt time.Time) string { + ago := now.Sub(createdAt) + + if ago < time.Hour { + return fmt.Sprintf("%d%s", int(ago.Minutes()), "m") + } + if ago < 24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours()), "h") + } + if ago < 30*24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d") + } + + return createdAt.Format("Jan _2, 2006") +} + func Humanize(s string) string { // Replaces - and _ with spaces. replace := "_-" diff --git a/utils/utils_test.go b/utils/utils_test.go index 0891c2a39..5dc7b2478 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -6,7 +6,6 @@ import ( ) func TestFuzzyAgo(t *testing.T) { - cases := map[string]string{ "1s": "less than a minute ago", "30s": "less than a minute ago", @@ -36,3 +35,29 @@ func TestFuzzyAgo(t *testing.T) { } } } + +func TestFuzzyAgoAbbr(t *testing.T) { + const form = "2006-Jan-02 15:04:05" + now, _ := time.Parse(form, "2020-Nov-22 14:00:00") + + cases := map[string]string{ + "2020-Nov-22 14:00:00": "0m", + "2020-Nov-22 13:59:00": "1m", + "2020-Nov-22 13:30:00": "30m", + "2020-Nov-22 13:00:00": "1h", + "2020-Nov-22 02:00:00": "12h", + "2020-Nov-21 14:00:00": "1d", + "2020-Nov-07 14:00:00": "15d", + "2020-Oct-24 14:00:00": "29d", + "2020-Oct-23 14:00:00": "Oct 23, 2020", + "2019-Nov-22 14:00:00": "Nov 22, 2019", + } + + for createdAt, expected := range cases { + d, _ := time.Parse(form, createdAt) + fuzzy := FuzzyAgoAbbr(now, d) + if fuzzy != expected { + t.Errorf("unexpected fuzzy duration abbr value: %s for %s", fuzzy, createdAt) + } + } +} From 8c5e5a382094551df9ea768e0fc8b7bfb0a52472 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 23 Nov 2020 18:37:27 +0300 Subject: [PATCH 063/129] Appease the linter --- api/reaction_groups_test.go | 10 +++++----- pkg/cmd/issue/view/view.go | 30 +++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/reaction_groups_test.go b/api/reaction_groups_test.go index f2fa7b1d4..4e501c338 100644 --- a/api/reaction_groups_test.go +++ b/api/reaction_groups_test.go @@ -17,19 +17,19 @@ func Test_String(t *testing.T) { }, "non-empty reaction groups": { rgs: []ReactionGroup{ - ReactionGroup{ + { Content: "LAUGH", Users: ReactionGroupUsers{TotalCount: 0}, }, - ReactionGroup{ + { Content: "HOORAY", Users: ReactionGroupUsers{TotalCount: 1}, }, - ReactionGroup{ + { Content: "CONFUSED", Users: ReactionGroupUsers{TotalCount: 0}, }, - ReactionGroup{ + { Content: "HEART", Users: ReactionGroupUsers{TotalCount: 2}, }, @@ -38,7 +38,7 @@ func Test_String(t *testing.T) { }, "reaction groups with unmapped emoji": { rgs: []ReactionGroup{ - ReactionGroup{ + { Content: "UNKNOWN", Users: ReactionGroupUsers{TotalCount: 1}, }, diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 759789036..9e81115f8 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -128,7 +128,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { fmt.Fprintln(out, "--") if len(issue.Comments.Nodes) > 0 { - fmt.Fprintf(out, rawIssueComments(issue.Comments)) + fmt.Fprint(out, rawIssueComments(issue.Comments)) } return nil @@ -137,7 +137,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { func rawIssueComments(comments api.IssueComments) string { var b strings.Builder for _, comment := range comments.Nodes { - fmt.Fprintf(&b, rawIssueComment(comment)) + fmt.Fprint(&b, rawIssueComment(comment)) } return b.String() } @@ -160,13 +160,13 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { // Header (Title and State) fmt.Fprintln(out, cs.Bold(issue.Title)) - fmt.Fprintln(out, fmt.Sprintf( - "%s • %s opened %s • %s", + fmt.Fprintf(out, + "%s • %s opened %s • %s\n", issueStateTitleWithColor(cs, issue.State), issue.Author.Login, utils.FuzzyAgo(ago), utils.Pluralize(issue.Comments.TotalCount, "comment"), - )) + ) // Reactions if reactions := issue.ReactionGroups.String(); reactions != "" { @@ -211,7 +211,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { if err != nil { return err } - fmt.Fprintf(out, comments) + fmt.Fprint(out, comments) } // Footer @@ -227,7 +227,7 @@ func issueComments(io *iostreams.IOStreams, comments api.IssueComments) (string, hiddenCount := comments.TotalCount - retrievedCount if hiddenCount > 0 { - fmt.Fprintf(&b, cs.Gray(fmt.Sprintf("———————— Hiding %v comments ————————", hiddenCount))) + fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Hiding %v comments ————————", hiddenCount))) fmt.Fprintf(&b, "\n\n\n") } @@ -237,14 +237,14 @@ func issueComments(io *iostreams.IOStreams, comments api.IssueComments) (string, if err != nil { return "", err } - fmt.Fprintf(&b, cmt) + fmt.Fprint(&b, cmt) if last { fmt.Fprintln(&b) } } if hiddenCount > 0 { - fmt.Fprintf(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) fmt.Fprintln(&b) } @@ -256,17 +256,17 @@ func issueComment(io *iostreams.IOStreams, comment api.IssueComment, newest bool cs := io.ColorScheme() // Header - fmt.Fprintf(&b, cs.Bold(comment.Author.Login)) + fmt.Fprint(&b, cs.Bold(comment.Author.Login)) if comment.AuthorAssociation != "NONE" { - fmt.Fprintf(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) } - fmt.Fprintf(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) if comment.IncludesCreatedEdit { - fmt.Fprintf(&b, cs.Bold(" • edited")) + fmt.Fprint(&b, cs.Bold(" • edited")) } if newest { - fmt.Fprintf(&b, cs.Bold(" • ")) - fmt.Fprintf(&b, cs.CyanBold("Newest comment")) + fmt.Fprint(&b, cs.Bold(" • ")) + fmt.Fprint(&b, cs.CyanBold("Newest comment")) } fmt.Fprintln(&b) From bad5a5942761eaeebb51356079e99c9c4d757ace Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 24 Nov 2020 11:18:20 +0300 Subject: [PATCH 064/129] Update non-tty comment output --- .../issueView_previewFullComments.json | 2 +- pkg/cmd/issue/view/view.go | 31 ++++++++++--------- pkg/cmd/issue/view/view_test.go | 22 ++++++------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json index 2805c9694..78fe07597 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json @@ -85,7 +85,7 @@ "authorAssociation": "NONE", "body": "Comment 1", "createdAt": "2020-01-01T12:00:00Z", - "includesCreatedEdit": false, + "includesCreatedEdit": true, "reactionGroups": [ { "content": "CONFUSED", diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 9e81115f8..b1f4cba82 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -27,9 +27,10 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - SelectorArg string - WebMode bool - Comments int + SelectorArg string + WebMode bool + Comments int + CommentsProvided bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -54,6 +55,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman // support `-R, --repo` override opts.BaseRepo = f.BaseRepo + opts.CommentsProvided = cmd.Flags().Changed("comments") + if len(args) > 0 { opts.SelectorArg = args[0] } @@ -66,7 +69,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") - cmd.Flags().IntVarP(&opts.Comments, "comments", "c", 1, "View issue comments") + cmd.Flags().IntVarP(&opts.Comments, "comments", "c", 1, "View issue comments sorted by most recent") cmd.Flags().Lookup("comments").NoOptDefVal = "30" return cmd @@ -104,6 +107,11 @@ func viewRun(opts *ViewOptions) error { if opts.IO.IsStdoutTTY() { return printHumanIssuePreview(opts.IO, issue) } + + if opts.CommentsProvided { + return printRawIssueComments(opts.IO.Out, issue) + } + return printRawIssuePreview(opts.IO.Out, issue) } @@ -122,30 +130,25 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { 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) - fmt.Fprintln(out, "--") fmt.Fprintln(out, issue.Body) - fmt.Fprintln(out, "--") - - if len(issue.Comments.Nodes) > 0 { - fmt.Fprint(out, rawIssueComments(issue.Comments)) - } - return nil } -func rawIssueComments(comments api.IssueComments) string { +func printRawIssueComments(out io.Writer, issue *api.Issue) error { var b strings.Builder - for _, comment := range comments.Nodes { + for _, comment := range issue.Comments.Nodes { fmt.Fprint(&b, rawIssueComment(comment)) } - return b.String() + fmt.Fprint(out, b.String()) + return nil } func rawIssueComment(comment api.IssueComment) string { var b strings.Builder fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) + fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) fmt.Fprintln(&b, "--") fmt.Fprintln(&b, comment.Body) fmt.Fprintln(&b, "--") diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index cb1b1604e..977e73dad 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -360,7 +360,7 @@ func TestIssueView_tty_Comments(t *testing.T) { expectedOutputs: []string{ `some title`, `some body`, - `monalisa • Jan 1, 2020`, + `monalisa • Jan 1, 2020 • edited`, `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, `Comment 1`, `johnnytest \(contributor\) • Jan 1, 2020`, @@ -428,36 +428,35 @@ func TestIssueView_nontty_Comments(t *testing.T) { fixture: "./fixtures/issueView_previewSingleComment.json", expectedOutputs: []string{ `title:\tsome title`, + `state:\tOPEN`, `author:\tmarseilles`, `comments:\t5`, `some body`, - `author:\tmarseilles`, - `association:\tcollaborator`, - `Comment 5`, }, }, "with default comments flag": { cli: "123 --comments", fixture: "./fixtures/issueView_previewFullComments.json", expectedOutputs: []string{ - `title:\tsome title`, - `author:\tmarseilles`, - `comments:\t5`, - `some body`, `author:\tmonalisa`, `association:\t`, + `edited:\ttrue`, `Comment 1`, `author:\tjohnnytest`, `association:\tcontributor`, + `edited:\tfalse`, `Comment 2`, `author:\telvisp`, `association:\tmember`, + `edited:\tfalse`, `Comment 3`, `author:\tloislane`, `association:\towner`, + `edited:\tfalse`, `Comment 4`, `author:\tmarseilles`, `association:\tcollaborator`, + `edited:\tfalse`, `Comment 5`, }, }, @@ -465,18 +464,17 @@ func TestIssueView_nontty_Comments(t *testing.T) { cli: "123 --comments=3", fixture: "./fixtures/issueView_previewThreeComments.json", expectedOutputs: []string{ - `title:\tsome title`, - `author:\tmarseilles`, - `comments:\t5`, - `some body`, `author:\telvisp`, `association:\tmember`, + `edited:\tfalse`, `Comment 3`, `author:\tloislane`, `association:\towner`, + `edited:\tfalse`, `Comment 4`, `author:\tmarseilles`, `association:\tcollaborator`, + `edited:\tfalse`, `Comment 5`, }, }, From 326fe371c67ba02a16182fd05428411b5e9e02cb Mon Sep 17 00:00:00 2001 From: Florian Thomas Date: Sun, 18 Oct 2020 16:34:32 +0100 Subject: [PATCH 065/129] add `gist clone` command This adds the ability to clone a gist. Usage: ```sh $ gh gist clone 5b0e0062eb8e9654adad7bb1d81cc75f $ gh gist clone https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f ``` This closes #2115. --- pkg/cmd/gist/clone/clone.go | 101 ++++++++++++++++++++++++++ pkg/cmd/gist/clone/clone_test.go | 118 +++++++++++++++++++++++++++++++ pkg/cmd/gist/gist.go | 2 + 3 files changed, 221 insertions(+) create mode 100644 pkg/cmd/gist/clone/clone.go create mode 100644 pkg/cmd/gist/clone/clone_test.go diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go new file mode 100644 index 000000000..8bdc932ae --- /dev/null +++ b/pkg/cmd/gist/clone/clone.go @@ -0,0 +1,101 @@ +package clone + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type CloneOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + + GitArgs []string + Directory string + Gist string +} + +func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command { + opts := &CloneOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + DisableFlagsInUseLine: true, + + Use: "clone [] [-- ...]", + Args: cmdutil.MinimumArgs(1, "cannot clone: gist argument required"), + Short: "Clone a gist locally", + Long: heredoc.Doc(` + Clone a GitHub gist locally. + + A gist can be supplied as argument in either of the following formats: + - by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f + - by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f" + + Pass additional 'git clone' flags by listing them after '--'. + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Gist = args[0] + opts.GitArgs = args[1:] + + if runF != nil { + return runF(opts) + } + + return cloneRun(opts) + }, + } + + cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } + return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)} + }) + + return cmd +} + +func cloneRun(opts *CloneOptions) error { + gistURL := opts.Gist + + if !git.IsURL(gistURL) { + cfg, err := opts.Config() + if err != nil { + return err + } + hostname := ghinstance.OverridableDefault() + protocol, err := cfg.Get(hostname, "git_protocol") + if err != nil { + return err + } + gistURL = formatRemoteURL(hostname, gistURL, protocol) + } + + _, err := git.RunClone(gistURL, opts.GitArgs) + if err != nil { + return err + } + + return nil +} + +func formatRemoteURL(hostname string, gistID string, protocol string) string { + if protocol == "ssh" { + return fmt.Sprintf("git@gist.%s:%s.git", hostname, gistID) + } + + return fmt.Sprintf("https://gist.%s/%s.git", hostname, gistID) +} diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go new file mode 100644 index 000000000..8faf3d40c --- /dev/null +++ b/pkg/cmd/gist/clone/clone_test.go @@ -0,0 +1,118 @@ +package clone + +import ( + "net/http" + "strings" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { + io, stdin, stdout, stderr := iostreams.Test() + fac := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return httpClient, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + cmd := NewCmdClone(fac, nil) + + argv, err := shlex.Split(cli) + cmd.SetArgs(argv) + + cmd.SetIn(stdin) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + if err != nil { + panic(err) + } + + _, err = cmd.ExecuteC() + + if err != nil { + return nil, err + } + + return &test.CmdOut{OutBuf: stdout, ErrBuf: stderr}, nil +} + +func Test_GistClone(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + { + name: "shorthand", + args: "GIST", + want: "git clone https://gist.github.com/GIST.git", + }, + { + name: "shorthand with directory", + args: "GIST target_directory", + want: "git clone https://gist.github.com/GIST.git target_directory", + }, + { + name: "clone arguments", + args: "GIST -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git", + }, + { + name: "clone arguments with directory", + args: "GIST target_directory -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git target_directory", + }, + { + name: "HTTPS URL", + args: "https://gist.github.com/OWNER/GIST", + want: "git clone https://gist.github.com/OWNER/GIST", + }, + { + name: "SSH URL", + args: "git@gist.github.com:GIST.git", + want: "git clone git@gist.github.com:GIST.git", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + httpClient := &http.Client{Transport: reg} + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + + output, err := runCloneCommand(httpClient, tt.args) + if err != nil { + t.Fatalf("error running command `gist clone`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, 1, cs.Count) + assert.Equal(t, tt.want, strings.Join(cs.Calls[0].Args, " ")) + reg.Verify(t) + }) + } +} + +func Test_GistClone_flagError(t *testing.T) { + _, err := runCloneCommand(nil, "--depth 1 GIST") + if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." { + t.Errorf("unexpected error %v", err) + } +} diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index 0abfc8591..df7e0f575 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -2,6 +2,7 @@ package gist import ( "github.com/MakeNowJust/heredoc" + gistCloneCmd "github.com/cli/cli/pkg/cmd/gist/clone" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" gistDeleteCmd "github.com/cli/cli/pkg/cmd/gist/delete" gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit" @@ -26,6 +27,7 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { }, } + cmd.AddCommand(gistCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) From 40c4007d98f5d851ff46a3f3d18abd68c232391b Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 7 Dec 2020 15:01:55 -0800 Subject: [PATCH 066/129] rename create->set --- pkg/cmd/secret/secret.go | 7 ++-- pkg/cmd/secret/{create => set}/http.go | 4 +- .../secret/{create/create.go => set/set.go} | 34 +++++++-------- .../create_test.go => set/set_test.go} | 42 ++++++++++--------- 4 files changed, 45 insertions(+), 42 deletions(-) rename pkg/cmd/secret/{create => set}/http.go (98%) rename pkg/cmd/secret/{create/create.go => set/set.go} (83%) rename pkg/cmd/secret/{create/create_test.go => set/set_test.go} (92%) diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 4932b6407..407d04029 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -5,8 +5,8 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" - cmdCreate "github.com/cli/cli/pkg/cmd/secret/create" cmdList "github.com/cli/cli/pkg/cmd/secret/list" + cmdSet "github.com/cli/cli/pkg/cmd/secret/set" ) func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { @@ -15,14 +15,15 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { Short: "Manage GitHub secrets", Long: heredoc.Doc(` Secrets can be set at the repository or organization level for use in GitHub Actions. - Run "gh help secret add" to learn how to get started. + Run "gh help secret set" to learn how to get started. `), } cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) - cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) + // TODO add delete return cmd } diff --git a/pkg/cmd/secret/create/http.go b/pkg/cmd/secret/set/http.go similarity index 98% rename from pkg/cmd/secret/create/http.go rename to pkg/cmd/secret/set/http.go index 51d270b83..bd7759fa5 100644 --- a/pkg/cmd/secret/create/http.go +++ b/pkg/cmd/secret/set/http.go @@ -1,4 +1,4 @@ -package create +package set import ( "bytes" @@ -64,7 +64,7 @@ func putSecret(client *api.Client, host, path string, payload SecretPayload) err return client.REST(host, "PUT", path, requestBody, nil) } -func putOrgSecret(client *api.Client, pk *PubKey, host string, opts CreateOptions, eValue string) error { +func putOrgSecret(client *api.Client, pk *PubKey, host string, opts SetOptions, eValue string) error { secretName := opts.SecretName orgName := opts.OrgName visibility := opts.Visibility diff --git a/pkg/cmd/secret/create/create.go b/pkg/cmd/secret/set/set.go similarity index 83% rename from pkg/cmd/secret/create/create.go rename to pkg/cmd/secret/set/set.go index bdd8d46be..c1f31c371 100644 --- a/pkg/cmd/secret/create/create.go +++ b/pkg/cmd/secret/set/set.go @@ -1,4 +1,4 @@ -package create +package set import ( "encoding/base64" @@ -20,7 +20,7 @@ import ( "golang.org/x/crypto/nacl/box" ) -type CreateOptions struct { +type SetOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -34,23 +34,23 @@ type CreateOptions struct { RepositoryNames []string } -func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { - opts := &CreateOptions{ +func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { + opts := &SetOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, } cmd := &cobra.Command{ - Use: "create ", - Short: "Create secrets", - Long: "Locally encrypt a new secret and send it to GitHub for storage.", + Use: "set ", + Short: "Create or update secrets", + Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.", Example: heredoc.Doc(` - $ cat SECRET.txt | gh secret create NEW_SECRET - $ gh secret create NEW_SECRET -b"some literal value" - $ gh secret create NEW_SECRET -b"@file.json" - $ gh secret create ORG_SECRET --org - $ gh secret create ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3" - $ gh secret create ORG_SECRET --org=anotherOrg --visibility="all" + $ cat SECRET.txt | gh secret set NEW_SECRET + $ gh secret set NEW_SECRET -b"some literal value" + $ gh secret set NEW_SECRET -b"@file.json" + $ gh secret set ORG_SECRET --org + $ gh secret set ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3" + $ gh secret set ORG_SECRET --org=anotherOrg --visibility="all" `), Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { @@ -93,7 +93,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return runF(opts) } - return createRun(opts) + return setRun(opts) }, } cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") @@ -105,7 +105,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmd } -func createRun(opts *CreateOptions) error { +func setRun(opts *SetOptions) error { body, err := getBody(opts) if err != nil { return fmt.Errorf("did not understand secret body: %w", err) @@ -154,13 +154,13 @@ func createRun(opts *CreateOptions) error { err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) } if err != nil { - return fmt.Errorf("failed to create secret: %w", err) + return fmt.Errorf("failed to set secret: %w", err) } return nil } -func getBody(opts *CreateOptions) (body []byte, err error) { +func getBody(opts *SetOptions) (body []byte, err error) { if opts.Body == "-" { body, err = ioutil.ReadAll(opts.IO.In) if err != nil { diff --git a/pkg/cmd/secret/create/create_test.go b/pkg/cmd/secret/set/set_test.go similarity index 92% rename from pkg/cmd/secret/create/create_test.go rename to pkg/cmd/secret/set/set_test.go index 365fd84a5..35f76ddd5 100644 --- a/pkg/cmd/secret/create/create_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -1,4 +1,4 @@ -package create +package set import ( "bytes" @@ -18,11 +18,11 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewCmdCreate(t *testing.T) { +func TestNewCmdSet(t *testing.T) { tests := []struct { name string cli string - wants CreateOptions + wants SetOptions stdinTTY bool wantsErr bool }{ @@ -60,7 +60,7 @@ func TestNewCmdCreate(t *testing.T) { { name: "explicit org with selected repo", cli: "--org=coolOrg -vselected -rcoolRepo cool_secret", - wants: CreateOptions{ + wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisSelected, RepositoryNames: []string{"coolRepo"}, @@ -71,7 +71,7 @@ func TestNewCmdCreate(t *testing.T) { { name: "explicit org with selected repos", cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, - wants: CreateOptions{ + wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisSelected, RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, @@ -82,7 +82,7 @@ func TestNewCmdCreate(t *testing.T) { { name: "repo", cli: `cool_secret -b"a secret"`, - wants: CreateOptions{ + wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisPrivate, Body: "a secret", @@ -92,7 +92,7 @@ func TestNewCmdCreate(t *testing.T) { { name: "implicit org", cli: `cool_secret --org -b"@cool.json"`, - wants: CreateOptions{ + wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisPrivate, Body: "@cool.json", @@ -102,7 +102,7 @@ func TestNewCmdCreate(t *testing.T) { { name: "vis all", cli: `cool_secret --org -b"@cool.json" -vall`, - wants: CreateOptions{ + wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisAll, Body: "@cool.json", @@ -123,8 +123,8 @@ func TestNewCmdCreate(t *testing.T) { argv, err := shlex.Split(tt.cli) assert.NoError(t, err) - var gotOpts *CreateOptions - cmd := NewCmdCreate(f, func(opts *CreateOptions) error { + var gotOpts *SetOptions + cmd := NewCmdSet(f, func(opts *SetOptions) error { gotOpts = opts return nil }) @@ -149,7 +149,7 @@ func TestNewCmdCreate(t *testing.T) { } } -func Test_createRun_repo(t *testing.T) { +func Test_setRun_repo(t *testing.T) { reg := &httpmock.Registry{} reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"), @@ -163,7 +163,7 @@ func Test_createRun_repo(t *testing.T) { io, _, _, _ := iostreams.Test() - opts := &CreateOptions{ + opts := &SetOptions{ BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -175,7 +175,7 @@ func Test_createRun_repo(t *testing.T) { RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}), } - err := createRun(opts) + err := setRun(opts) assert.NoError(t, err) reg.Verify(t) @@ -189,30 +189,30 @@ func Test_createRun_repo(t *testing.T) { assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") } -func Test_createRun_org(t *testing.T) { +func Test_setRun_org(t *testing.T) { tests := []struct { name string - opts *CreateOptions + opts *SetOptions wantVisibility string wantRepositories []int }{ { name: "explicit org name", - opts: &CreateOptions{ + opts: &SetOptions{ OrgName: "UmbrellaCorporation", Visibility: shared.VisAll, }, }, { name: "implicit org name", - opts: &CreateOptions{ + opts: &SetOptions{ OrgName: "@owner", Visibility: shared.VisPrivate, }, }, { name: "selected visibility", - opts: &CreateOptions{ + opts: &SetOptions{ OrgName: "UmbrellaCorporation", Visibility: shared.VisSelected, RepositoryNames: []string{"birkin", "wesker"}, @@ -257,7 +257,7 @@ func Test_createRun_org(t *testing.T) { // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}) - err := createRun(tt.opts) + err := setRun(tt.opts) assert.NoError(t, err) reg.Verify(t) @@ -319,7 +319,7 @@ func Test_getBody(t *testing.T) { tt.bodyArg = fmt.Sprintf("@%s", tmpfile.Name()) } - body, err := getBody(&CreateOptions{ + body, err := getBody(&SetOptions{ Body: tt.bodyArg, IO: io, }) @@ -332,3 +332,5 @@ func Test_getBody(t *testing.T) { } } + +// TODO test updating org secret's repo lists From bec5e0cd77782587bc9ac4ab653107a939972b9c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 7 Dec 2020 15:02:43 -0500 Subject: [PATCH 067/129] Address PR comments --- api/queries_comments.go | 65 +++++ api/queries_issue.go | 23 +- api/reaction_groups.go | 29 +- api/reaction_groups_test.go | 109 ++++--- pkg/cmd/issue/shared/lookup.go | 12 +- .../issueView_previewFullComments.json | 77 +---- .../issueView_previewThreeComments.json | 265 ------------------ pkg/cmd/issue/view/view.go | 78 ++++-- pkg/cmd/issue/view/view_test.go | 96 +++---- 9 files changed, 253 insertions(+), 501 deletions(-) create mode 100644 api/queries_comments.go delete mode 100644 pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json diff --git a/api/queries_comments.go b/api/queries_comments.go new file mode 100644 index 000000000..70ea382a1 --- /dev/null +++ b/api/queries_comments.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" +) + +type Comments struct { + Nodes []Comment + TotalCount int + PageInfo PageInfo +} + +type Comment struct { + Author Author + AuthorAssociation string + Body string + CreatedAt time.Time + IncludesCreatedEdit bool + ReactionGroups 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 +} diff --git a/api/queries_issue.go b/api/queries_issue.go index 1aa83ad89..17f32eabd 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -33,7 +33,7 @@ type Issue struct { Body string CreatedAt time.Time UpdatedAt time.Time - Comments IssueComments + Comments Comments Author Author Assignees struct { Nodes []struct { @@ -68,20 +68,6 @@ type IssuesDisabledError struct { error } -type IssueComments struct { - Nodes []IssueComment - TotalCount int -} - -type IssueComment struct { - Author Author - AuthorAssociation string - Body string - CreatedAt time.Time - IncludesCreatedEdit bool - ReactionGroups ReactionGroups -} - type Author struct { Login string } @@ -335,7 +321,7 @@ loop: return &res, nil } -func IssueByNumber(client *Client, repo ghrepo.Interface, number, comments int) (*Issue, error) { +func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { type response struct { Repository struct { Issue Issue @@ -344,7 +330,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number, comments int) } query := ` - query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!, $comments: Int!) { + query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) { repository(owner: $owner, name: $repo) { hasIssuesEnabled issue(number: $issue_number) { @@ -356,7 +342,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number, comments int) author { login } - comments(last: $comments) { + comments(last: 1) { nodes { author { login @@ -417,7 +403,6 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number, comments int) "owner": repo.RepoOwner(), "repo": repo.RepoName(), "issue_number": number, - "comments": comments, } var resp response diff --git a/api/reaction_groups.go b/api/reaction_groups.go index 68c527079..381504dd7 100644 --- a/api/reaction_groups.go +++ b/api/reaction_groups.go @@ -1,10 +1,5 @@ package api -import ( - "fmt" - "strings" -) - type ReactionGroups []ReactionGroup type ReactionGroup struct { @@ -16,28 +11,12 @@ type ReactionGroupUsers struct { TotalCount int } -func (rg ReactionGroup) String() string { - c := rg.Users.TotalCount - if c == 0 { - return "" - } - e := reactionEmoji[rg.Content] - if e == "" { - return "" - } - return fmt.Sprintf("%v %s", c, e) +func (rg ReactionGroup) Count() int { + return rg.Users.TotalCount } -func (rgs ReactionGroups) String() string { - var rs []string - - for _, rg := range rgs { - if r := rg.String(); r != "" { - rs = append(rs, r) - } - } - - return strings.Join(rs, " • ") +func (rg ReactionGroup) Emoji() string { + return reactionEmoji[rg.Content] } var reactionEmoji = map[string]string{ diff --git a/api/reaction_groups_test.go b/api/reaction_groups_test.go index 4e501c338..e30a9e1f8 100644 --- a/api/reaction_groups_test.go +++ b/api/reaction_groups_test.go @@ -8,48 +8,93 @@ import ( func Test_String(t *testing.T) { tests := map[string]struct { - rgs ReactionGroups - output string + rg ReactionGroup + emoji string + count int }{ - "empty reaction groups": { - rgs: []ReactionGroup{}, - output: `^$`, + "empty reaction group": { + rg: ReactionGroup{}, + emoji: "", + count: 0, }, - "non-empty reaction groups": { - rgs: []ReactionGroup{ - { - Content: "LAUGH", - Users: ReactionGroupUsers{TotalCount: 0}, - }, - { - Content: "HOORAY", - Users: ReactionGroupUsers{TotalCount: 1}, - }, - { - Content: "CONFUSED", - Users: ReactionGroupUsers{TotalCount: 0}, - }, - { - Content: "HEART", - Users: ReactionGroupUsers{TotalCount: 2}, - }, + "unknown reaction group": { + rg: ReactionGroup{ + Content: "UNKNOWN", + Users: ReactionGroupUsers{TotalCount: 1}, }, - output: `^1 \x{1f389} • 2 \x{2764}\x{fe0f}$`, + emoji: "", + count: 1, }, - "reaction groups with unmapped emoji": { - rgs: []ReactionGroup{ - { - Content: "UNKNOWN", - Users: ReactionGroupUsers{TotalCount: 1}, - }, + "thumbs up reaction group": { + rg: ReactionGroup{ + Content: "THUMBS_UP", + Users: ReactionGroupUsers{TotalCount: 2}, }, - output: `^$`, + emoji: "\U0001f44d", + count: 2, + }, + "thumbs down reaction group": { + rg: ReactionGroup{ + Content: "THUMBS_DOWN", + Users: ReactionGroupUsers{TotalCount: 3}, + }, + emoji: "\U0001f44e", + count: 3, + }, + "laugh reaction group": { + rg: ReactionGroup{ + Content: "LAUGH", + Users: ReactionGroupUsers{TotalCount: 4}, + }, + emoji: "\U0001f604", + count: 4, + }, + "hooray reaction group": { + rg: ReactionGroup{ + Content: "HOORAY", + Users: ReactionGroupUsers{TotalCount: 5}, + }, + emoji: "\U0001f389", + count: 5, + }, + "confused reaction group": { + rg: ReactionGroup{ + Content: "CONFUSED", + Users: ReactionGroupUsers{TotalCount: 6}, + }, + emoji: "\U0001f615", + count: 6, + }, + "heart reaction group": { + rg: ReactionGroup{ + Content: "HEART", + Users: ReactionGroupUsers{TotalCount: 7}, + }, + emoji: "\u2764\ufe0f", + count: 7, + }, + "rocket reaction group": { + rg: ReactionGroup{ + Content: "ROCKET", + Users: ReactionGroupUsers{TotalCount: 8}, + }, + emoji: "\U0001f680", + count: 8, + }, + "eyes reaction group": { + rg: ReactionGroup{ + Content: "EYES", + Users: ReactionGroupUsers{TotalCount: 9}, + }, + emoji: "\U0001f440", + count: 9, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { - assert.Regexp(t, tt.output, tt.rgs.String()) + assert.Equal(t, tt.emoji, tt.rg.Emoji()) + assert.Equal(t, tt.count, tt.rg.Count()) }) } } diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 09be5bab6..675574f7c 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -11,7 +11,7 @@ import ( "github.com/cli/cli/internal/ghrepo" ) -func IssueWithCommentsFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, comments int) (*api.Issue, ghrepo.Interface, error) { +func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { issueNumber, baseRepo := issueMetadataFromURL(arg) if baseRepo == nil { @@ -30,14 +30,10 @@ func IssueWithCommentsFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.I } } - issue, err := issueFromNumber(apiClient, baseRepo, issueNumber, comments) + issue, err := issueFromNumber(apiClient, baseRepo, issueNumber) return issue, baseRepo, err } -func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { - return IssueWithCommentsFromArg(apiClient, baseRepoFn, arg, 0) -} - var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`) func issueMetadataFromURL(s string) (int, ghrepo.Interface) { @@ -60,6 +56,6 @@ func issueMetadataFromURL(s string) (int, ghrepo.Interface) { return issueNumber, repo } -func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber, comments int) (*api.Issue, error) { - return api.IssueByNumber(apiClient, repo, issueNumber, comments) +func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber int) (*api.Issue, error) { + return api.IssueByNumber(apiClient, repo, issueNumber) } diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json index 78fe07597..d2cd27f30 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json @@ -1,81 +1,7 @@ { "data": { "repository": { - "hasIssuesEnabled": true, "issue": { - "number": 123, - "body": "some body", - "title": "some title", - "state": "OPEN", - "createdAt": "2020-01-01T12:00:00Z", - "author": { - "login": "marseilles" - }, - "assignees": { - "nodes": [], - "totalCount": 0 - }, - "labels": { - "nodes": [], - "totalCount": 0 - }, - "projectcards": { - "nodes": [], - "totalCount": 0 - }, - "milestone": { - "title": "" - }, - "reactionGroups": [ - { - "content": "CONFUSED", - "users": { - "totalCount": 0 - } - }, - { - "content": "EYES", - "users": { - "totalCount": 0 - } - }, - { - "content": "HEART", - "users": { - "totalCount": 0 - } - }, - { - "content": "HOORAY", - "users": { - "totalCount": 0 - } - }, - { - "content": "LAUGH", - "users": { - "totalCount": 0 - } - }, - { - "content": "ROCKET", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_DOWN", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_UP", - "users": { - "totalCount": 0 - } - } - ], "comments": { "nodes": [ { @@ -375,8 +301,7 @@ } ], "totalCount": 5 - }, - "url": "https://github.com/OWNER/REPO/issues/123" + } } } } diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json deleted file mode 100644 index 74d5945e1..000000000 --- a/pkg/cmd/issue/view/fixtures/issueView_previewThreeComments.json +++ /dev/null @@ -1,265 +0,0 @@ -{ - "data": { - "repository": { - "hasIssuesEnabled": true, - "issue": { - "number": 123, - "body": "some body", - "title": "some title", - "state": "OPEN", - "createdAt": "2020-01-01T12:00:00Z", - "author": { - "login": "marseilles" - }, - "assignees": { - "nodes": [], - "totalCount": 0 - }, - "labels": { - "nodes": [], - "totalCount": 0 - }, - "projectcards": { - "nodes": [], - "totalCount": 0 - }, - "milestone": { - "title": "" - }, - "reactionGroups": [ - { - "content": "CONFUSED", - "users": { - "totalCount": 0 - } - }, - { - "content": "EYES", - "users": { - "totalCount": 0 - } - }, - { - "content": "HEART", - "users": { - "totalCount": 0 - } - }, - { - "content": "HOORAY", - "users": { - "totalCount": 0 - } - }, - { - "content": "LAUGH", - "users": { - "totalCount": 0 - } - }, - { - "content": "ROCKET", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_DOWN", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_UP", - "users": { - "totalCount": 0 - } - } - ], - "comments": { - "nodes": [ - { - "author": { - "login": "elvisp" - }, - "authorAssociation": "MEMBER", - "body": "Comment 3", - "createdAt": "2020-01-01T12:00:00Z", - "includesCreatedEdit": false, - "reactionGroups": [ - { - "content": "CONFUSED", - "users": { - "totalCount": 0 - } - }, - { - "content": "EYES", - "users": { - "totalCount": 0 - } - }, - { - "content": "HEART", - "users": { - "totalCount": 0 - } - }, - { - "content": "HOORAY", - "users": { - "totalCount": 0 - } - }, - { - "content": "LAUGH", - "users": { - "totalCount": 0 - } - }, - { - "content": "ROCKET", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_DOWN", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_UP", - "users": { - "totalCount": 0 - } - } - ] - }, - { - "author": { - "login": "loislane" - }, - "authorAssociation": "OWNER", - "body": "Comment 4", - "createdAt": "2020-01-01T12:00:00Z", - "includesCreatedEdit": false, - "reactionGroups": [ - { - "content": "CONFUSED", - "users": { - "totalCount": 0 - } - }, - { - "content": "EYES", - "users": { - "totalCount": 0 - } - }, - { - "content": "HEART", - "users": { - "totalCount": 0 - } - }, - { - "content": "HOORAY", - "users": { - "totalCount": 0 - } - }, - { - "content": "LAUGH", - "users": { - "totalCount": 0 - } - }, - { - "content": "ROCKET", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_DOWN", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_UP", - "users": { - "totalCount": 0 - } - } - ] - }, - { - "author": { - "login": "marseilles" - }, - "authorAssociation": "COLLABORATOR", - "body": "Comment 5", - "createdAt": "2020-01-01T12:00:00Z", - "includesCreatedEdit": false, - "reactionGroups": [ - { - "content": "CONFUSED", - "users": { - "totalCount": 0 - } - }, - { - "content": "EYES", - "users": { - "totalCount": 0 - } - }, - { - "content": "HEART", - "users": { - "totalCount": 0 - } - }, - { - "content": "HOORAY", - "users": { - "totalCount": 0 - } - }, - { - "content": "LAUGH", - "users": { - "totalCount": 0 - } - }, - { - "content": "ROCKET", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_DOWN", - "users": { - "totalCount": 0 - } - }, - { - "content": "THUMBS_UP", - "users": { - "totalCount": 0 - } - } - ] - } - ], - "totalCount": 5 - }, - "url": "https://github.com/OWNER/REPO/issues/123" - } - } - } -} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b1f4cba82..063be82c8 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/briandowns/spinner" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" @@ -27,10 +28,9 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - SelectorArg string - WebMode bool - Comments int - CommentsProvided bool + SelectorArg string + WebMode bool + Comments bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -55,8 +55,6 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.CommentsProvided = cmd.Flags().Changed("comments") - if len(args) > 0 { opts.SelectorArg = args[0] } @@ -69,8 +67,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") - cmd.Flags().IntVarP(&opts.Comments, "comments", "c", 1, "View issue comments sorted by most recent") - cmd.Flags().Lookup("comments").NoOptDefVal = "30" + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments") return cmd } @@ -82,20 +79,37 @@ func viewRun(opts *ViewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - issue, _, err := issueShared.IssueWithCommentsFromArg(apiClient, opts.BaseRepo, opts.SelectorArg, opts.Comments) + issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) if err != nil { return err } - openURL := issue.URL - if opts.WebMode { + openURL := issue.URL if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } + if opts.Comments { + var s *spinner.Spinner + if opts.IO.IsStdoutTTY() { + s = utils.Spinner(opts.IO.ErrOut) + utils.StartSpinner(s) + } + + comments, err := api.CommentsForIssue(apiClient, repo, issue) + if err != nil { + return err + } + issue.Comments = *comments + + if opts.IO.IsStdoutTTY() { + utils.StopSpinner(s) + } + } + opts.IO.DetectTerminalTheme() err = opts.IO.StartPager() @@ -108,7 +122,7 @@ func viewRun(opts *ViewOptions) error { return printHumanIssuePreview(opts.IO, issue) } - if opts.CommentsProvided { + if opts.Comments { return printRawIssueComments(opts.IO.Out, issue) } @@ -144,7 +158,7 @@ func printRawIssueComments(out io.Writer, issue *api.Issue) error { return nil } -func rawIssueComment(comment api.IssueComment) string { +func rawIssueComment(comment api.Comment) string { var b strings.Builder fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) @@ -172,7 +186,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { ) // Reactions - if reactions := issue.ReactionGroups.String(); reactions != "" { + if reactions := reactionGroupList(issue.ReactionGroups); reactions != "" { fmt.Fprint(out, reactions) fmt.Fprintln(out) } @@ -210,7 +224,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { // Comments if issue.Comments.TotalCount > 0 { - comments, err := issueComments(io, issue.Comments) + comments, err := issueCommentList(io, issue.Comments) if err != nil { return err } @@ -223,20 +237,20 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { return nil } -func issueComments(io *iostreams.IOStreams, comments api.IssueComments) (string, error) { +func issueCommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { var b strings.Builder cs := io.ColorScheme() retrievedCount := len(comments.Nodes) hiddenCount := comments.TotalCount - retrievedCount if hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Hiding %v comments ————————", hiddenCount))) + fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) fmt.Fprintf(&b, "\n\n\n") } for i, comment := range comments.Nodes { last := i+1 == retrievedCount - cmt, err := issueComment(io, comment, last) + cmt, err := formatIssueComment(io, comment, last) if err != nil { return "", err } @@ -254,7 +268,7 @@ func issueComments(io *iostreams.IOStreams, comments api.IssueComments) (string, return b.String(), nil } -func issueComment(io *iostreams.IOStreams, comment api.IssueComment, newest bool) (string, error) { +func formatIssueComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { var b strings.Builder cs := io.ColorScheme() @@ -274,7 +288,7 @@ func issueComment(io *iostreams.IOStreams, comment api.IssueComment, newest bool fmt.Fprintln(&b) // Reactions - if reactions := comment.ReactionGroups.String(); reactions != "" { + if reactions := reactionGroupList(comment.ReactionGroups); reactions != "" { fmt.Fprint(&b, reactions) fmt.Fprintln(&b) } @@ -334,3 +348,27 @@ func issueProjectList(issue api.Issue) string { } return list } + +func reactionGroupList(rgs api.ReactionGroups) string { + var rs []string + + for _, rg := range rgs { + if r := formatReactionGroup(rg); r != "" { + rs = append(rs, r) + } + } + + return strings.Join(rs, " • ") +} + +func formatReactionGroup(rg api.ReactionGroup) string { + c := rg.Count() + if c == 0 { + return "" + } + e := rg.Emoji() + if e == "" { + return "" + } + return fmt.Sprintf("%v %s", c, e) +} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 977e73dad..2b94c5a70 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,11 +2,13 @@ package view import ( "bytes" + "fmt" "io/ioutil" "net/http" "os/exec" "testing" + "github.com/briandowns/spinner" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" @@ -14,6 +16,7 @@ import ( "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" + "github.com/cli/cli/utils" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -337,26 +340,31 @@ func TestIssueView_web_urlArg(t *testing.T) { func TestIssueView_tty_Comments(t *testing.T) { tests := map[string]struct { cli string - fixture string + fixtures map[string]string expectedOutputs []string wantsErr bool }{ "without comments flag": { - cli: "123", - fixture: "./fixtures/issueView_previewSingleComment.json", + cli: "123", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + }, expectedOutputs: []string{ `some title`, `some body`, - `———————— Hiding 4 comments ————————`, + `———————— Not showing 4 comments ————————`, `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, `Comment 5`, `Use --comments to view the full conversation`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, - "with default comments flag": { - cli: "123 --comments", - fixture: "./fixtures/issueView_previewFullComments.json", + "with comments flag": { + cli: "123 --comments", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + }, expectedOutputs: []string{ `some title`, `some body`, @@ -374,35 +382,19 @@ func TestIssueView_tty_Comments(t *testing.T) { `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, - "with specified comments flag": { - cli: "123 --comments=3", - fixture: "./fixtures/issueView_previewThreeComments.json", - expectedOutputs: []string{ - `some title`, - `some body`, - `———————— Hiding 2 comments ————————`, - `elvisp \(member\) • Jan 1, 2020`, - `Comment 3`, - `loislane \(owner\) • Jan 1, 2020`, - `Comment 4`, - `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, - `Comment 5`, - `Use --comments to view the full conversation`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "with incorrect comments flag": { + "with invalid comments flag": { cli: "123 --comments 3", - fixture: "", wantsErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { + stubSpinner() http := &httpmock.Registry{} defer http.Verify(t) - if tc.fixture != "" { - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) } output, err := runCommand(http, true, tc.cli) if tc.wantsErr { @@ -419,13 +411,15 @@ func TestIssueView_tty_Comments(t *testing.T) { func TestIssueView_nontty_Comments(t *testing.T) { tests := map[string]struct { cli string - fixture string + fixtures map[string]string expectedOutputs []string wantsErr bool }{ "without comments flag": { - cli: "123", - fixture: "./fixtures/issueView_previewSingleComment.json", + cli: "123", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + }, expectedOutputs: []string{ `title:\tsome title`, `state:\tOPEN`, @@ -434,9 +428,12 @@ func TestIssueView_nontty_Comments(t *testing.T) { `some body`, }, }, - "with default comments flag": { - cli: "123 --comments", - fixture: "./fixtures/issueView_previewFullComments.json", + "with comments flag": { + cli: "123 --comments", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + }, expectedOutputs: []string{ `author:\tmonalisa`, `association:\t`, @@ -460,27 +457,8 @@ func TestIssueView_nontty_Comments(t *testing.T) { `Comment 5`, }, }, - "with specified comments flag": { - cli: "123 --comments=3", - fixture: "./fixtures/issueView_previewThreeComments.json", - expectedOutputs: []string{ - `author:\telvisp`, - `association:\tmember`, - `edited:\tfalse`, - `Comment 3`, - `author:\tloislane`, - `association:\towner`, - `edited:\tfalse`, - `Comment 4`, - `author:\tmarseilles`, - `association:\tcollaborator`, - `edited:\tfalse`, - `Comment 5`, - }, - }, - "with incorrect comments flag": { + "with invalid comments flag": { cli: "123 --comments 3", - fixture: "", wantsErr: true, }, } @@ -488,8 +466,9 @@ func TestIssueView_nontty_Comments(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - if tc.fixture != "" { - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) } output, err := runCommand(http, false, tc.cli) if tc.wantsErr { @@ -502,3 +481,8 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } + +func stubSpinner() { + utils.StartSpinner = func(_ *spinner.Spinner) {} + utils.StopSpinner = func(_ *spinner.Spinner) {} +} From b2edf782cf205d158f8a300eeb730e2604ec5ebf Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 8 Dec 2020 14:16:40 -0500 Subject: [PATCH 068/129] Reverse order of issue lookup checks --- pkg/cmd/issue/shared/lookup.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 675574f7c..6e716f6da 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -14,14 +14,6 @@ import ( func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { issueNumber, baseRepo := issueMetadataFromURL(arg) - if baseRepo == nil { - var err error - baseRepo, err = baseRepoFn() - if err != nil { - return nil, nil, fmt.Errorf("could not determine base repo: %w", err) - } - } - if issueNumber == 0 { var err error issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) @@ -30,6 +22,14 @@ func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, er } } + if baseRepo == nil { + var err error + baseRepo, err = baseRepoFn() + if err != nil { + return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + } + } + issue, err := issueFromNumber(apiClient, baseRepo, issueNumber) return issue, baseRepo, err } From 9f101ff0a2ddf31d67a3066beb3a4634977900f9 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 8 Dec 2020 10:24:59 -0500 Subject: [PATCH 069/129] Add comments to pr view --- api/queries_comments.go | 36 ++ api/queries_pr.go | 50 +++ pkg/cmd/issue/view/view.go | 4 +- .../fixtures/prViewPreviewFullComments.json | 308 ++++++++++++++++++ .../fixtures/prViewPreviewSingleComment.json | 155 +++++++++ pkg/cmd/pr/view/view.go | 185 ++++++++++- pkg/cmd/pr/view/view_test.go | 214 ++++++++++-- 7 files changed, 910 insertions(+), 42 deletions(-) create mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json create mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json diff --git a/api/queries_comments.go b/api/queries_comments.go index 70ea382a1..ccbabfe5d 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -63,3 +63,39 @@ func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Com 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 +} diff --git a/api/queries_pr.go b/api/queries_pr.go index 4433ef373..ad4f23644 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -137,6 +137,8 @@ type PullRequest struct { Milestone struct { Title string } + Comments Comments + ReactionGroups ReactionGroups } type NotFoundError struct { @@ -603,6 +605,30 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu milestone{ title } + comments(last: 1) { + nodes { + author { + login + } + authorAssociation + body + createdAt + includesCreatedEdit + reactionGroups { + content + users { + totalCount + } + } + } + totalCount + } + reactionGroups { + content + users { + totalCount + } + } } } }` @@ -712,6 +738,30 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea milestone{ title } + comments(last: 1) { + nodes { + author { + login + } + authorAssociation + body + createdAt + includesCreatedEdit + reactionGroups { + content + users { + totalCount + } + } + } + totalCount + } + reactionGroups { + content + users { + totalCount + } + } } } } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 063be82c8..58883965d 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -152,13 +152,13 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { func printRawIssueComments(out io.Writer, issue *api.Issue) error { var b strings.Builder for _, comment := range issue.Comments.Nodes { - fmt.Fprint(&b, rawIssueComment(comment)) + fmt.Fprint(&b, formatRawIssueComment(comment)) } fmt.Fprint(out, b.String()) return nil } -func rawIssueComment(comment api.Comment) string { +func formatRawIssueComment(comment api.Comment) string { var b strings.Builder fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json new file mode 100644 index 000000000..cadbf294b --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json @@ -0,0 +1,308 @@ +{ + "data": { + "repository": { + "pullRequest": { + "comments": { + "nodes": [ + { + "author": { + "login": "monalisa" + }, + "authorAssociation": "NONE", + "body": "Comment 1", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": true, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 7 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 8 + } + } + ] + }, + { + "author": { + "login": "johnnytest" + }, + "authorAssociation": "CONTRIBUTOR", + "body": "Comment 2", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "elvisp" + }, + "authorAssociation": "MEMBER", + "body": "Comment 3", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "loislane" + }, + "authorAssociation": "OWNER", + "body": "Comment 4", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json new file mode 100644 index 000000000..c6ddc4321 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json @@ -0,0 +1,155 @@ +{ + "data": { + "repository": { + "pullRequest": { + "number": 12, + "title": "some title", + "state": "OPEN", + "body": "some body", + "url": "https://github.com/OWNER/REPO/pull/12", + "author": { + "login": "nobody" + }, + "assignees": { + "nodes": [], + "totalcount": 0 + }, + "labels": { + "nodes": [], + "totalcount": 0 + }, + "projectcards": { + "nodes": [], + "totalcount": 0 + }, + "milestone": { + "title": "" + }, + "commits": { + "totalCount": 12 + }, + "baseRefName": "master", + "headRefName": "blueberries", + "headRepositoryOwner": { + "login": "hubot" + }, + "isCrossRepository": true, + "isDraft": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 9dcdb8f8b..df1b1590a 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -3,11 +3,14 @@ package view import ( "errors" "fmt" + "io" "net/http" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" + "github.com/briandowns/spinner" "github.com/cli/cli/api" "github.com/cli/cli/context" "github.com/cli/cli/internal/config" @@ -30,6 +33,7 @@ type ViewOptions struct { SelectorArg string BrowserMode bool + Comments bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -73,6 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments") return cmd } @@ -84,21 +89,39 @@ func viewRun(opts *ViewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) if err != nil { return err } - openURL := pr.URL connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() if opts.BrowserMode { + openURL := pr.URL if connectedToTerminal { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } + if opts.Comments { + var s *spinner.Spinner + if connectedToTerminal { + s = utils.Spinner(opts.IO.ErrOut) + utils.StartSpinner(s) + } + + comments, err := api.CommentsForPullRequest(apiClient, repo, pr) + if err != nil { + return err + } + pr.Comments = *comments + + if connectedToTerminal { + utils.StopSpinner(s) + } + } + opts.IO.DetectTerminalTheme() err = opts.IO.StartPager() @@ -111,6 +134,10 @@ func viewRun(opts *ViewOptions) error { return printHumanPrPreview(opts.IO, pr) } + if opts.Comments { + return printRawPrComments(opts.IO.Out, pr) + } + return printRawPrPreview(opts.IO, pr) } @@ -140,22 +167,46 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { return nil } +func printRawPrComments(out io.Writer, pr *api.PullRequest) error { + var b strings.Builder + for _, comment := range pr.Comments.Nodes { + fmt.Fprint(&b, formatRawPrComment(comment)) + } + fmt.Fprint(out, b.String()) + return nil +} + +func formatRawPrComment(comment api.Comment) string { + var b strings.Builder + fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) + fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) + fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) + fmt.Fprintln(&b, "--") + fmt.Fprintln(&b, comment.Body) + fmt.Fprintln(&b, "--") + return b.String() +} + func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { out := io.Out - cs := io.ColorScheme() // Header (Title and State) fmt.Fprintln(out, cs.Bold(pr.Title)) - fmt.Fprintf(out, "%s", shared.StateTitleWithColor(cs, *pr)) - fmt.Fprintln(out, cs.Gray(fmt.Sprintf( - " • %s wants to merge %s into %s from %s", + fmt.Fprintf(out, + "%s • %s wants to merge %s into %s from %s\n", + shared.StateTitleWithColor(cs, *pr), pr.Author.Login, utils.Pluralize(pr.Commits.TotalCount, "commit"), pr.BaseRefName, pr.HeadRefName, - ))) - fmt.Fprintln(out) + ) + + // Reactions + if reactions := reactionGroupList(pr.ReactionGroups); reactions != "" { + fmt.Fprint(out, reactions) + fmt.Fprintln(out) + } // Metadata if reviewers := prReviewerList(*pr, cs); reviewers != "" { @@ -180,22 +231,102 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { } // Body - if pr.Body != "" { - fmt.Fprintln(out) - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(pr.Body, style, "") + fmt.Fprintln(out) + if pr.Body == "" { + pr.Body = "_No description provided_" + } + style := markdown.GetStyle(io.TerminalTheme()) + md, err := markdown.Render(pr.Body, style, "") + if err != nil { + return err + } + fmt.Fprint(out, md) + fmt.Fprintln(out) + + // Comments + if pr.Comments.TotalCount > 0 { + comments, err := prCommentList(io, pr.Comments) if err != nil { return err } - fmt.Fprintln(out, md) + fmt.Fprint(out, comments) } - fmt.Fprintln(out) // Footer - fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) + fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s"), pr.URL) + return nil } +func prCommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + retrievedCount := len(comments.Nodes) + hiddenCount := comments.TotalCount - retrievedCount + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) + fmt.Fprintf(&b, "\n\n\n") + } + + for i, comment := range comments.Nodes { + last := i+1 == retrievedCount + cmt, err := formatPrComment(io, comment, last) + if err != nil { + return "", err + } + fmt.Fprint(&b, cmt) + if last { + fmt.Fprintln(&b) + } + } + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprintln(&b) + } + + return b.String(), nil +} + +func formatPrComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + + // Header + fmt.Fprint(&b, cs.Bold(comment.Author.Login)) + if comment.AuthorAssociation != "NONE" { + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) + } + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) + if comment.IncludesCreatedEdit { + fmt.Fprint(&b, cs.Bold(" • edited")) + } + if newest { + fmt.Fprint(&b, cs.Bold(" • ")) + fmt.Fprint(&b, cs.CyanBold("Newest comment")) + } + fmt.Fprintln(&b) + + // Reactions + if reactions := reactionGroupList(comment.ReactionGroups); reactions != "" { + fmt.Fprint(&b, reactions) + fmt.Fprintln(&b) + } + + // Body + if comment.Body != "" { + style := markdown.GetStyle(io.TerminalTheme()) + md, err := markdown.Render(comment.Body, style, "") + if err != nil { + return "", err + } + fmt.Fprint(&b, md) + } + + return b.String(), nil +} + const ( requestedReviewState = "REQUESTED" // This is our own state for review request approvedReviewState = "APPROVED" @@ -373,3 +504,27 @@ func prStateWithDraft(pr *api.PullRequest) string { return pr.State } + +func reactionGroupList(rgs api.ReactionGroups) string { + var rs []string + + for _, rg := range rgs { + if r := formatReactionGroup(rg); r != "" { + rs = append(rs, r) + } + } + + return strings.Join(rs, " • ") +} + +func formatReactionGroup(rg api.ReactionGroup) string { + c := rg.Count() + if c == 0 { + return "" + } + e := rg.Emoji() + if e == "" { + return "" + } + return fmt.Sprintf("%v %s", c, e) +} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 6dc591243..678629831 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -2,13 +2,14 @@ package view import ( "bytes" + "fmt" "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" + "github.com/briandowns/spinner" "github.com/cli/cli/context" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" @@ -18,6 +19,7 @@ import ( "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" + "github.com/cli/cli/utils" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -64,6 +66,15 @@ func Test_NewCmdView(t *testing.T) { isTTY: true, wantErr: "argument required when using the --repo flag", }, + { + name: "comments", + args: "123 -c", + isTTY: true, + want: ViewOptions{ + SelectorArg: "123", + Comments: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,13 +115,6 @@ func Test_NewCmdView(t *testing.T) { } } -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -332,7 +336,7 @@ func TestPRView_Preview_nontty(t *testing.T) { t.Errorf("error running command `%v`: %v", tc.args, err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -370,7 +374,7 @@ func TestPRView_Preview(t *testing.T) { `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, `Milestone:.*uluru\n`, `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, "Open PR with reviewers by number": { @@ -381,7 +385,7 @@ func TestPRView_Preview(t *testing.T) { `Blueberries are from a fork`, `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, "Open PR with metadata by branch": { @@ -396,7 +400,7 @@ func TestPRView_Preview(t *testing.T) { `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\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, }, }, "Open PR for the current branch": { @@ -477,7 +481,7 @@ func TestPRView_Preview(t *testing.T) { t.Errorf("error running command `%v`: %v", tc.args, err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -506,8 +510,8 @@ func TestPRView_web_currentBranch(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pull/10 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/pull/10 in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") @@ -567,13 +571,13 @@ func TestPRView_web_numberArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_numberArgWithHash(t *testing.T) { @@ -598,13 +602,13 @@ func TestPRView_web_numberArgWithHash(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_urlArg(t *testing.T) { @@ -628,13 +632,13 @@ func TestPRView_web_urlArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_branchArg(t *testing.T) { @@ -661,13 +665,13 @@ func TestPRView_web_branchArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_branchWithOwnerArg(t *testing.T) { @@ -695,11 +699,171 @@ func TestPRView_web_branchWithOwnerArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(t, "", output.String()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/hubot/REPO/pull/23") + assert.Equal(t, "https://github.com/hubot/REPO/pull/23", url) +} + +func TestPRView_tty_Comments(t *testing.T) { + tests := map[string]struct { + branch string + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + branch: "master", + cli: "123", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + }, + expectedOutputs: []string{ + `some title`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`, + `some body`, + `———————— Not showing 4 comments ————————`, + `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680}`, + `Comment 5`, + `Use --comments to view the full conversation`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "with comments flag": { + branch: "master", + cli: "123 --comments", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", + }, + expectedOutputs: []string{ + `some title`, + `some body`, + `monalisa • Jan 1, 2020 • edited`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, + `Comment 1`, + `johnnytest \(contributor\) • Jan 1, 2020`, + `Comment 2`, + `elvisp \(member\) • Jan 1, 2020`, + `Comment 3`, + `loislane \(owner\) • Jan 1, 2020`, + `Comment 4`, + `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "with invalid comments flag": { + branch: "master", + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + stubSpinner() + 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)) + } + output, err := runCommand(http, tt.branch, true, tt.cli) + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tt.expectedOutputs...) + }) + } +} + +func TestPRView_nontty_Comments(t *testing.T) { + tests := map[string]struct { + branch string + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + branch: "master", + cli: "123", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + }, + expectedOutputs: []string{ + `title:\tsome title`, + `state:\tOPEN`, + `author:\tnobody`, + `url:\thttps://github.com/OWNER/REPO/pull/12`, + `some body`, + }, + }, + "with comments flag": { + branch: "master", + cli: "123 --comments", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", + }, + expectedOutputs: []string{ + `author:\tmonalisa`, + `association:\t`, + `edited:\ttrue`, + `Comment 1`, + `author:\tjohnnytest`, + `association:\tcontributor`, + `edited:\tfalse`, + `Comment 2`, + `author:\telvisp`, + `association:\tmember`, + `edited:\tfalse`, + `Comment 3`, + `author:\tloislane`, + `association:\towner`, + `edited:\tfalse`, + `Comment 4`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `edited:\tfalse`, + `Comment 5`, + }, + }, + "with invalid comments flag": { + branch: "master", + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tt := range tests { + 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)) + } + output, err := runCommand(http, tt.branch, false, tt.cli) + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tt.expectedOutputs...) + }) + } +} + +func stubSpinner() { + utils.StartSpinner = func(_ *spinner.Spinner) {} + utils.StopSpinner = func(_ *spinner.Spinner) {} } From d7f68e9ee2182417e9124bace0890203f6993868 Mon Sep 17 00:00:00 2001 From: Ismael Luceno Date: Tue, 8 Dec 2020 22:39:38 +0100 Subject: [PATCH 070/129] Filter flags taken from LDFLAGS into CGO_LDFLAGS Make sure we take only flags compatible with cgo. Solves: https://github.com/cli/cli/issues/2577 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 22f3672b7..e29d67b07 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CGO_CPPFLAGS ?= ${CPPFLAGS} export CGO_CPPFLAGS CGO_CFLAGS ?= ${CFLAGS} export CGO_CFLAGS -CGO_LDFLAGS ?= ${LDFLAGS} +CGO_LDFLAGS ?= $(filter -g -L% -l% -O%,${LDFLAGS}) export CGO_LDFLAGS GO_LDFLAGS := -X github.com/cli/cli/internal/build.Version=$(GH_VERSION) $(GO_LDFLAGS) From f853a4b0e2d389206a9f641325ed3ca9e94fb91d Mon Sep 17 00:00:00 2001 From: Dylan Strohschein Date: Wed, 9 Dec 2020 00:25:21 +0000 Subject: [PATCH 071/129] Allow API request to be made if the PR is in an unknown state --- pkg/cmd/pr/merge/merge.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index d72ed2ca0..abd761807 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -123,9 +123,6 @@ func mergeRun(opts *MergeOptions) error { if pr.Mergeable == "CONFLICTING" { err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", cs.Red("!"), pr.Number, pr.Title) return err - } else if pr.Mergeable == "UNKNOWN" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", cs.Red("!"), pr.Number, pr.Title) - return err } else if pr.State == "MERGED" { err := fmt.Errorf("%s Pull request #%d (%s) was already merged", cs.Red("!"), pr.Number, pr.Title) return err From efc05dee907cb764c659cf8f8a92d2c2652ce74d Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 9 Dec 2020 13:50:08 -0500 Subject: [PATCH 072/129] Use spinner helper --- pkg/cmd/issue/view/view.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 063be82c8..d8754edcb 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -8,7 +8,6 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/briandowns/spinner" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" @@ -93,21 +92,13 @@ func viewRun(opts *ViewOptions) error { } if opts.Comments { - var s *spinner.Spinner - if opts.IO.IsStdoutTTY() { - s = utils.Spinner(opts.IO.ErrOut) - utils.StartSpinner(s) - } - + opts.IO.StartProgressIndicator() comments, err := api.CommentsForIssue(apiClient, repo, issue) + opts.IO.StopProgressIndicator() if err != nil { return err } issue.Comments = *comments - - if opts.IO.IsStdoutTTY() { - utils.StopSpinner(s) - } } opts.IO.DetectTerminalTheme() From dee7077fcf80ab5675e2791f4a6bdca7d7cd629d Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 9 Dec 2020 14:35:29 -0500 Subject: [PATCH 073/129] Extract shared comment and reaction group code --- pkg/cmd/issue/view/view.go | 120 +----------------------- pkg/cmd/pr/shared/comments.go | 100 ++++++++++++++++++++ pkg/cmd/pr/shared/reaction_groups.go | 32 +++++++ pkg/cmd/pr/view/view.go | 135 ++------------------------- 4 files changed, 142 insertions(+), 245 deletions(-) create mode 100644 pkg/cmd/pr/shared/comments.go create mode 100644 pkg/cmd/pr/shared/reaction_groups.go diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 58883965d..76d32e032 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -123,7 +123,8 @@ func viewRun(opts *ViewOptions) error { } if opts.Comments { - return printRawIssueComments(opts.IO.Out, issue) + fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments)) + return nil } return printRawIssuePreview(opts.IO.Out, issue) @@ -149,26 +150,6 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { return nil } -func printRawIssueComments(out io.Writer, issue *api.Issue) error { - var b strings.Builder - for _, comment := range issue.Comments.Nodes { - fmt.Fprint(&b, formatRawIssueComment(comment)) - } - fmt.Fprint(out, b.String()) - return nil -} - -func formatRawIssueComment(comment api.Comment) string { - var b strings.Builder - fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) - fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) - fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) - fmt.Fprintln(&b, "--") - fmt.Fprintln(&b, comment.Body) - fmt.Fprintln(&b, "--") - return b.String() -} - func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { out := io.Out now := time.Now() @@ -186,7 +167,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { ) // Reactions - if reactions := reactionGroupList(issue.ReactionGroups); reactions != "" { + if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" { fmt.Fprint(out, reactions) fmt.Fprintln(out) } @@ -224,7 +205,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { // Comments if issue.Comments.TotalCount > 0 { - comments, err := issueCommentList(io, issue.Comments) + comments, err := prShared.CommentList(io, issue.Comments) if err != nil { return err } @@ -237,75 +218,6 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { return nil } -func issueCommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { - var b strings.Builder - cs := io.ColorScheme() - retrievedCount := len(comments.Nodes) - hiddenCount := comments.TotalCount - retrievedCount - - if hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) - fmt.Fprintf(&b, "\n\n\n") - } - - for i, comment := range comments.Nodes { - last := i+1 == retrievedCount - cmt, err := formatIssueComment(io, comment, last) - if err != nil { - return "", err - } - fmt.Fprint(&b, cmt) - if last { - fmt.Fprintln(&b) - } - } - - if hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) - fmt.Fprintln(&b) - } - - return b.String(), nil -} - -func formatIssueComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { - var b strings.Builder - cs := io.ColorScheme() - - // Header - fmt.Fprint(&b, cs.Bold(comment.Author.Login)) - if comment.AuthorAssociation != "NONE" { - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) - } - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) - if comment.IncludesCreatedEdit { - fmt.Fprint(&b, cs.Bold(" • edited")) - } - if newest { - fmt.Fprint(&b, cs.Bold(" • ")) - fmt.Fprint(&b, cs.CyanBold("Newest comment")) - } - fmt.Fprintln(&b) - - // Reactions - if reactions := reactionGroupList(comment.ReactionGroups); reactions != "" { - fmt.Fprint(&b, reactions) - fmt.Fprintln(&b) - } - - // Body - if comment.Body != "" { - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(comment.Body, style, "") - if err != nil { - return "", err - } - fmt.Fprint(&b, md) - } - - return b.String(), nil -} - func issueStateTitleWithColor(cs *iostreams.ColorScheme, state string) string { colorFunc := cs.ColorFromString(prShared.ColorForState(state)) return colorFunc(strings.Title(strings.ToLower(state))) @@ -348,27 +260,3 @@ func issueProjectList(issue api.Issue) string { } return list } - -func reactionGroupList(rgs api.ReactionGroups) string { - var rs []string - - for _, rg := range rgs { - if r := formatReactionGroup(rg); r != "" { - rs = append(rs, r) - } - } - - return strings.Join(rs, " • ") -} - -func formatReactionGroup(rg api.ReactionGroup) string { - c := rg.Count() - if c == 0 { - return "" - } - e := rg.Emoji() - if e == "" { - return "" - } - return fmt.Sprintf("%v %s", c, e) -} diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go new file mode 100644 index 000000000..f0d820c1f --- /dev/null +++ b/pkg/cmd/pr/shared/comments.go @@ -0,0 +1,100 @@ +package shared + +import ( + "fmt" + "strings" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/markdown" + "github.com/cli/cli/utils" +) + +func RawCommentList(comments api.Comments) string { + var b strings.Builder + for _, comment := range comments.Nodes { + fmt.Fprint(&b, formatRawComment(comment)) + } + return b.String() +} + +func formatRawComment(comment api.Comment) string { + var b strings.Builder + fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) + fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) + fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) + fmt.Fprintln(&b, "--") + fmt.Fprintln(&b, comment.Body) + fmt.Fprintln(&b, "--") + return b.String() +} + +func CommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + retrievedCount := len(comments.Nodes) + hiddenCount := comments.TotalCount - retrievedCount + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) + fmt.Fprintf(&b, "\n\n\n") + } + + for i, comment := range comments.Nodes { + last := i+1 == retrievedCount + cmt, err := formatComment(io, comment, last) + if err != nil { + return "", err + } + fmt.Fprint(&b, cmt) + if last { + fmt.Fprintln(&b) + } + } + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprintln(&b) + } + + return b.String(), nil +} + +func formatComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + + // Header + fmt.Fprint(&b, cs.Bold(comment.Author.Login)) + if comment.AuthorAssociation != "NONE" { + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) + } + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) + if comment.IncludesCreatedEdit { + fmt.Fprint(&b, cs.Bold(" • edited")) + } + if newest { + fmt.Fprint(&b, cs.Bold(" • ")) + fmt.Fprint(&b, cs.CyanBold("Newest comment")) + } + fmt.Fprintln(&b) + + // Reactions + if reactions := ReactionGroupList(comment.ReactionGroups); reactions != "" { + fmt.Fprint(&b, reactions) + fmt.Fprintln(&b) + } + + // Body + if comment.Body != "" { + style := markdown.GetStyle(io.TerminalTheme()) + md, err := markdown.Render(comment.Body, style, "") + if err != nil { + return "", err + } + fmt.Fprint(&b, md) + } + + return b.String(), nil +} diff --git a/pkg/cmd/pr/shared/reaction_groups.go b/pkg/cmd/pr/shared/reaction_groups.go new file mode 100644 index 000000000..caf972672 --- /dev/null +++ b/pkg/cmd/pr/shared/reaction_groups.go @@ -0,0 +1,32 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/cli/cli/api" +) + +func ReactionGroupList(rgs api.ReactionGroups) string { + var rs []string + + for _, rg := range rgs { + if r := formatReactionGroup(rg); r != "" { + rs = append(rs, r) + } + } + + return strings.Join(rs, " • ") +} + +func formatReactionGroup(rg api.ReactionGroup) string { + c := rg.Count() + if c == 0 { + return "" + } + e := rg.Emoji() + if e == "" { + return "" + } + return fmt.Sprintf("%v %s", c, e) +} diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index df1b1590a..8384d7af3 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -3,14 +3,11 @@ package view import ( "errors" "fmt" - "io" "net/http" "sort" "strings" - "time" "github.com/MakeNowJust/heredoc" - "github.com/briandowns/spinner" "github.com/cli/cli/api" "github.com/cli/cli/context" "github.com/cli/cli/internal/config" @@ -105,21 +102,13 @@ func viewRun(opts *ViewOptions) error { } if opts.Comments { - var s *spinner.Spinner - if connectedToTerminal { - s = utils.Spinner(opts.IO.ErrOut) - utils.StartSpinner(s) - } - + opts.IO.StartProgressIndicator() comments, err := api.CommentsForPullRequest(apiClient, repo, pr) + opts.IO.StopProgressIndicator() if err != nil { return err } pr.Comments = *comments - - if connectedToTerminal { - utils.StopSpinner(s) - } } opts.IO.DetectTerminalTheme() @@ -135,7 +124,8 @@ func viewRun(opts *ViewOptions) error { } if opts.Comments { - return printRawPrComments(opts.IO.Out, pr) + fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments)) + return nil } return printRawPrPreview(opts.IO, pr) @@ -167,26 +157,6 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { return nil } -func printRawPrComments(out io.Writer, pr *api.PullRequest) error { - var b strings.Builder - for _, comment := range pr.Comments.Nodes { - fmt.Fprint(&b, formatRawPrComment(comment)) - } - fmt.Fprint(out, b.String()) - return nil -} - -func formatRawPrComment(comment api.Comment) string { - var b strings.Builder - fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) - fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) - fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) - fmt.Fprintln(&b, "--") - fmt.Fprintln(&b, comment.Body) - fmt.Fprintln(&b, "--") - return b.String() -} - func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { out := io.Out cs := io.ColorScheme() @@ -203,7 +173,7 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { ) // Reactions - if reactions := reactionGroupList(pr.ReactionGroups); reactions != "" { + if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" { fmt.Fprint(out, reactions) fmt.Fprintln(out) } @@ -245,7 +215,7 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { // Comments if pr.Comments.TotalCount > 0 { - comments, err := prCommentList(io, pr.Comments) + comments, err := shared.CommentList(io, pr.Comments) if err != nil { return err } @@ -258,75 +228,6 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { return nil } -func prCommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { - var b strings.Builder - cs := io.ColorScheme() - retrievedCount := len(comments.Nodes) - hiddenCount := comments.TotalCount - retrievedCount - - if hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) - fmt.Fprintf(&b, "\n\n\n") - } - - for i, comment := range comments.Nodes { - last := i+1 == retrievedCount - cmt, err := formatPrComment(io, comment, last) - if err != nil { - return "", err - } - fmt.Fprint(&b, cmt) - if last { - fmt.Fprintln(&b) - } - } - - if hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) - fmt.Fprintln(&b) - } - - return b.String(), nil -} - -func formatPrComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { - var b strings.Builder - cs := io.ColorScheme() - - // Header - fmt.Fprint(&b, cs.Bold(comment.Author.Login)) - if comment.AuthorAssociation != "NONE" { - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) - } - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) - if comment.IncludesCreatedEdit { - fmt.Fprint(&b, cs.Bold(" • edited")) - } - if newest { - fmt.Fprint(&b, cs.Bold(" • ")) - fmt.Fprint(&b, cs.CyanBold("Newest comment")) - } - fmt.Fprintln(&b) - - // Reactions - if reactions := reactionGroupList(comment.ReactionGroups); reactions != "" { - fmt.Fprint(&b, reactions) - fmt.Fprintln(&b) - } - - // Body - if comment.Body != "" { - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(comment.Body, style, "") - if err != nil { - return "", err - } - fmt.Fprint(&b, md) - } - - return b.String(), nil -} - const ( requestedReviewState = "REQUESTED" // This is our own state for review request approvedReviewState = "APPROVED" @@ -504,27 +405,3 @@ func prStateWithDraft(pr *api.PullRequest) string { return pr.State } - -func reactionGroupList(rgs api.ReactionGroups) string { - var rs []string - - for _, rg := range rgs { - if r := formatReactionGroup(rg); r != "" { - rs = append(rs, r) - } - } - - return strings.Join(rs, " • ") -} - -func formatReactionGroup(rg api.ReactionGroup) string { - c := rg.Count() - if c == 0 { - return "" - } - e := rg.Emoji() - if e == "" { - return "" - } - return fmt.Sprintf("%v %s", c, e) -} From 486aa81dfe55c4276f5c67205b0272c9777c7b3f Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Dec 2020 15:32:02 -0800 Subject: [PATCH 074/129] validate secret name --- pkg/cmd/secret/set/set.go | 28 ++++++++++++++++++++++++++++ pkg/cmd/secret/set/set_test.go | 15 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index c1f31c371..458b36697 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "net/http" + "regexp" "strings" "github.com/MakeNowJust/heredoc" @@ -67,6 +68,11 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command opts.SecretName = args[0] + err := validSecretName(opts.SecretName) + if err != nil { + return err + } + if cmd.Flags().Changed("visibility") { if opts.OrgName == "" { return &cmdutil.FlagError{Err: errors.New( @@ -160,6 +166,28 @@ func setRun(opts *SetOptions) error { return nil } +func validSecretName(name string) error { + if name == "" { + return errors.New("secret name cannot be blank") + } + + if strings.HasPrefix(name, "GITHUB_") { + return errors.New("secret name cannot begin with GITHUB_") + } + + leadingNumber := regexp.MustCompile(`^[0-9]`) + if leadingNumber.MatchString(name) { + return errors.New("secret name cannot start with a number") + } + + validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`) + if !validChars.MatchString(name) { + return errors.New("secret name can only contain letters, numbers, and _") + } + + return nil +} + func getBody(opts *SetOptions) (body []byte, err error) { if opts.Body == "-" { body, err = ioutil.ReadAll(opts.IO.In) diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 35f76ddd5..7fc793024 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -109,6 +109,21 @@ func TestNewCmdSet(t *testing.T) { OrgName: "@owner", }, }, + { + name: "bad name prefix", + cli: `GITHUB_SECRET -b"cool"`, + wantsErr: true, + }, + { + name: "leading numbers in name", + cli: `123_SECRET -b"cool"`, + wantsErr: true, + }, + { + name: "invalid characters in name", + cli: `BAD-SECRET -b"cool"`, + wantsErr: true, + }, } for _, tt := range tests { From dbff17e6ed9b7418db5e4b86da557b836c5a387a Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Dec 2020 15:44:31 -0800 Subject: [PATCH 075/129] add removing secrets --- pkg/cmd/secret/remove/remove.go | 93 ++++++++++++++++ pkg/cmd/secret/remove/remove_test.go | 159 +++++++++++++++++++++++++++ pkg/cmd/secret/secret.go | 4 +- pkg/cmd/secret/set/set_test.go | 13 +-- 4 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/secret/remove/remove.go create mode 100644 pkg/cmd/secret/remove/remove_test.go diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go new file mode 100644 index 000000000..57057f9fa --- /dev/null +++ b/pkg/cmd/secret/remove/remove.go @@ -0,0 +1,93 @@ +package remove + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type RemoveOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SecretName string + OrgName string +} + +func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command { + opts := &RemoveOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an organization or repository secret", + Example: heredoc.Doc(` + $ gh secret remove REPO_SECRET + $ gh secret remove --org ORG_SECRET + $ gh secret remove --org="anotherOrg" ORG_SECRET + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SecretName = args[0] + + if runF != nil { + return runF(opts) + } + + return removeRun(opts) + }, + } + cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") + cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + + return cmd +} + +func removeRun(opts *RemoveOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + var baseRepo ghrepo.Interface + if opts.OrgName == "" || opts.OrgName == "@owner" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + host := ghinstance.OverridableDefault() + if opts.OrgName == "@owner" { + opts.OrgName = baseRepo.RepoOwner() + host = baseRepo.RepoHost() + } + + var path string + if opts.OrgName == "" { + path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) + } else { + path = fmt.Sprintf("orgs/%s/actions/secrets/%s", opts.OrgName, opts.SecretName) + } + + err = client.REST(host, "DELETE", path, nil, nil) + if err != nil { + return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) + } + + return nil +} diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go new file mode 100644 index 000000000..26751334d --- /dev/null +++ b/pkg/cmd/secret/remove/remove_test.go @@ -0,0 +1,159 @@ +package remove + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "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" +) + +func TestNewCmdRemove(t *testing.T) { + tests := []struct { + name string + cli string + wants RemoveOptions + wantsErr bool + }{ + { + name: "no args", + wantsErr: true, + }, + { + name: "implicit org", + cli: "cool --org", + wants: RemoveOptions{ + SecretName: "cool", + OrgName: "@owner", + }, + }, + { + name: "explicit org", + cli: "cool --org=anOrg", + wants: RemoveOptions{ + SecretName: "cool", + OrgName: "anOrg", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *RemoveOptions + cmd := NewCmdRemove(f, func(opts *RemoveOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName) + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + }) + } + +} + +func Test_removeRun_repo(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register( + httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/cool_secret"), + httpmock.StatusStringResponse(204, "No Content")) + + io, _, _, _ := iostreams.Test() + + opts := &RemoveOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + SecretName: "cool_secret", + } + + err := removeRun(opts) + assert.NoError(t, err) + + reg.Verify(t) +} + +func Test_removeRun_org(t *testing.T) { + tests := []struct { + name string + opts *RemoveOptions + }{ + { + name: "implicit org", + opts: &RemoveOptions{ + OrgName: "@owner", + }, + }, + { + name: "explicit org", + opts: &RemoveOptions{ + OrgName: "UmbrellaCorporation", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + impliedOrgName := "NeoUmbrella" + + orgName := tt.opts.OrgName + if orgName == "@owner" { + orgName = impliedOrgName + } + + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)), + httpmock.StatusStringResponse(204, "No Content")) + + io, _, _, _ := iostreams.Test() + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName(fmt.Sprintf("%s/repo", impliedOrgName)) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.IO = io + tt.opts.SecretName = "tVirus" + + err := removeRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + }) + } + +} diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 407d04029..2801d7c2b 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" cmdList "github.com/cli/cli/pkg/cmd/secret/list" + cmdRemove "github.com/cli/cli/pkg/cmd/secret/remove" cmdSet "github.com/cli/cli/pkg/cmd/secret/set" ) @@ -22,8 +23,9 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) + // TODO add success messages to these: cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) - // TODO add delete + cmd.AddCommand(cmdRemove.NewCmdRemove(f, nil)) return cmd } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 7fc793024..32d0d50da 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -172,17 +172,15 @@ func Test_setRun_repo(t *testing.T) { reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`)) - mockClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - io, _, _, _ := iostreams.Test() opts := &SetOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, - HttpClient: mockClient, IO: io, SecretName: "cool_secret", Body: "a secret", @@ -240,9 +238,10 @@ func Test_setRun_org(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} + impliedOrgName := "NeoUmbrella" orgName := tt.opts.OrgName if orgName == "@owner" { - orgName = "NeoUmbrella" + orgName = impliedOrgName } reg.Register(httpmock.REST("GET", @@ -261,7 +260,7 @@ func Test_setRun_org(t *testing.T) { io, _, _, _ := iostreams.Test() tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("NeoUmbrella/repo") + return ghrepo.FromFullName(fmt.Sprintf("%s/repo", impliedOrgName)) } tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil From 2248565839cd469bea56ecb41b88108d13455669 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Dec 2020 17:31:34 -0800 Subject: [PATCH 076/129] print success messages --- pkg/cmd/secret/remove/remove.go | 5 +++++ pkg/cmd/secret/secret.go | 1 - pkg/cmd/secret/set/set.go | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index 57057f9fa..0c061d6c3 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -89,5 +89,10 @@ func removeRun(opts *RemoveOptions) error { return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) } + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Removed secret %s\n", cs.SuccessIcon(), opts.SecretName) + } + return nil } diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 2801d7c2b..e8b82a41d 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -23,7 +23,6 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) - // TODO add success messages to these: cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) cmd.AddCommand(cmdRemove.NewCmdRemove(f, nil)) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 458b36697..610e85319 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -163,6 +163,11 @@ func setRun(opts *SetOptions) error { return fmt.Errorf("failed to set secret: %w", err) } + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Set secret %s\n", cs.SuccessIcon(), opts.SecretName) + } + return nil } From c036e6699c3c8450cb925542973d0b4762d3fa73 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 12:36:57 -0800 Subject: [PATCH 077/129] remove implied org functionality from secret list --- pkg/cmd/secret/list/list.go | 23 +++++++--------- pkg/cmd/secret/list/list_test.go | 45 ++++---------------------------- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index b5eb85dc5..e1c8e32f1 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -47,8 +47,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } - cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") - cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") return cmd } @@ -60,26 +59,21 @@ func listRun(opts *ListOptions) error { } client := api.NewClientFromHTTP(c) + orgName := opts.OrgName + var baseRepo ghrepo.Interface - if opts.OrgName == "" || opts.OrgName == "@owner" { + if orgName == "" { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) } } - orgName := opts.OrgName - host := ghinstance.OverridableDefault() - if orgName == "@owner" { - orgName = baseRepo.RepoOwner() - host = baseRepo.RepoHost() - } - var secrets []Secret - if orgName != "" { - secrets, err = getOrgSecrets(client, host, orgName) - } else { + if orgName == "" { secrets, err = getRepoSecrets(client, baseRepo) + } else { + secrets, err = getOrgSecrets(client, orgName) } if err != nil { @@ -131,7 +125,8 @@ func fmtVisibility(s Secret) string { return "" } -func getOrgSecrets(client *api.Client, host, orgName string) ([]Secret, error) { +func getOrgSecrets(client *api.Client, orgName string) ([]Secret, error) { + host := ghinstance.OverridableDefault() return getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) } diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 8a7077de1..960680455 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -31,15 +31,8 @@ func Test_NewCmdList(t *testing.T) { }, }, { - name: "implicit org", - cli: "--org", - wants: ListOptions{ - OrgName: "@owner", - }, - }, - { - name: "explicit org", - cli: "--org=UmbrellaCorporation", + name: "org", + cli: "-oUmbrellaCorporation", wants: ListOptions{ OrgName: "UmbrellaCorporation", }, @@ -105,7 +98,7 @@ func Test_listRun(t *testing.T) { }, }, { - name: "explicit org tty", + name: "org tty", tty: true, opts: &ListOptions{ OrgName: "UmbrellaCorporation", @@ -117,7 +110,7 @@ func Test_listRun(t *testing.T) { }, }, { - name: "explicit org not tty", + name: "org not tty", tty: false, opts: &ListOptions{ OrgName: "UmbrellaCorporation", @@ -128,30 +121,6 @@ func Test_listRun(t *testing.T) { "SECRET_THREE\t1975-11-30\tSELECTED", }, }, - { - name: "implicit org not tty", - tty: false, - opts: &ListOptions{ - OrgName: "@owner", - }, - wantOut: []string{ - "SECRET_ONE\t1988-10-11\tALL", - "SECRET_TWO\t2020-12-04\tPRIVATE", - "SECRET_THREE\t1975-11-30\tSELECTED", - }, - }, - { - name: "implicit org not tty", - tty: true, - opts: &ListOptions{ - OrgName: "@owner", - }, - wantOut: []string{ - "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", - "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", - "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", - }, - }, } for _, tt := range tests { @@ -195,11 +164,7 @@ func Test_listRun(t *testing.T) { Visibility: shared.VisSelected, }, } - if tt.opts.OrgName == "@owner" { - path = "orgs/owner/actions/secrets" - } else { - path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) - } + path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) } reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) From 1675bd9249c7ac559480dc131fe9650a5b98da90 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 12:45:38 -0800 Subject: [PATCH 078/129] remove implied org functionality from secret remove also fill in missing test cases >_> --- pkg/cmd/secret/remove/remove.go | 22 +++++------------ pkg/cmd/secret/remove/remove_test.go | 36 +++++++++++++--------------- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index 0c061d6c3..ba26353ab 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" @@ -31,12 +30,7 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "remove ", Short: "Remove an organization or repository secret", - Example: heredoc.Doc(` - $ gh secret remove REPO_SECRET - $ gh secret remove --org ORG_SECRET - $ gh secret remove --org="anotherOrg" ORG_SECRET - `), - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo @@ -50,8 +44,7 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co return removeRun(opts) }, } - cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") - cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") return cmd } @@ -63,20 +56,16 @@ func removeRun(opts *RemoveOptions) error { } client := api.NewClientFromHTTP(c) + orgName := opts.OrgName + var baseRepo ghrepo.Interface - if opts.OrgName == "" || opts.OrgName == "@owner" { + if orgName == "" { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) } } - host := ghinstance.OverridableDefault() - if opts.OrgName == "@owner" { - opts.OrgName = baseRepo.RepoOwner() - host = baseRepo.RepoHost() - } - var path string if opts.OrgName == "" { path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) @@ -84,6 +73,7 @@ func removeRun(opts *RemoveOptions) error { path = fmt.Sprintf("orgs/%s/actions/secrets/%s", opts.OrgName, opts.SecretName) } + host := ghinstance.OverridableDefault() err = client.REST(host, "DELETE", path, nil, nil) if err != nil { return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go index 26751334d..efd1f660d 100644 --- a/pkg/cmd/secret/remove/remove_test.go +++ b/pkg/cmd/secret/remove/remove_test.go @@ -26,16 +26,15 @@ func TestNewCmdRemove(t *testing.T) { wantsErr: true, }, { - name: "implicit org", - cli: "cool --org", + name: "repo", + cli: "cool", wants: RemoveOptions{ SecretName: "cool", - OrgName: "@owner", }, }, { - name: "explicit org", - cli: "cool --org=anOrg", + name: "org", + cli: "cool --org anOrg", wants: RemoveOptions{ SecretName: "cool", OrgName: "anOrg", @@ -109,13 +108,11 @@ func Test_removeRun_org(t *testing.T) { opts *RemoveOptions }{ { - name: "implicit org", - opts: &RemoveOptions{ - OrgName: "@owner", - }, + name: "repo", + opts: &RemoveOptions{}, }, { - name: "explicit org", + name: "org", opts: &RemoveOptions{ OrgName: "UmbrellaCorporation", }, @@ -126,21 +123,22 @@ func Test_removeRun_org(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - impliedOrgName := "NeoUmbrella" - orgName := tt.opts.OrgName - if orgName == "@owner" { - orgName = impliedOrgName - } - reg.Register( - httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)), - httpmock.StatusStringResponse(204, "No Content")) + if orgName == "" { + reg.Register( + httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/tVirus"), + httpmock.StatusStringResponse(204, "No Content")) + } else { + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)), + httpmock.StatusStringResponse(204, "No Content")) + } io, _, _, _ := iostreams.Test() tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName(fmt.Sprintf("%s/repo", impliedOrgName)) + return ghrepo.FromFullName("owner/repo") } tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil From 4e8a6805756cf8bf50c848002169a79649c8afd3 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 13:22:11 -0800 Subject: [PATCH 079/129] remove implied org functionality from secret set --- pkg/cmd/secret/set/http.go | 7 ++++-- pkg/cmd/secret/set/set.go | 18 ++++++---------- pkg/cmd/secret/set/set_test.go | 39 ++++++++-------------------------- 3 files changed, 20 insertions(+), 44 deletions(-) diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index bd7759fa5..303ace1b9 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/secret/shared" ) @@ -45,7 +46,8 @@ func getPubKey(client *api.Client, host, path string) (*PubKey, error) { return &pk, nil } -func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) { +func getOrgPublicKey(client *api.Client, orgName string) (*PubKey, error) { + host := ghinstance.OverridableDefault() return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) } @@ -64,10 +66,11 @@ func putSecret(client *api.Client, host, path string, payload SecretPayload) err return client.REST(host, "PUT", path, requestBody, nil) } -func putOrgSecret(client *api.Client, pk *PubKey, host string, opts SetOptions, eValue string) error { +func putOrgSecret(client *api.Client, pk *PubKey, opts SetOptions, eValue string) error { secretName := opts.SecretName orgName := opts.OrgName visibility := opts.Visibility + host := ghinstance.OverridableDefault() var repositoryIDs []int var err error diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 610e85319..f15e216e2 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -12,7 +12,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmd/secret/shared" "github.com/cli/cli/pkg/cmdutil" @@ -102,8 +101,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return setRun(opts) }, } - cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") - cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") cmd.Flags().StringVarP(&opts.Body, "body", "b", "-", "Provide either a literal string or a file path; prepend file paths with an @. Reads from STDIN if not provided.") @@ -123,23 +121,19 @@ func setRun(opts *SetOptions) error { } client := api.NewClientFromHTTP(c) + orgName := opts.OrgName + var baseRepo ghrepo.Interface - if opts.OrgName == "" || opts.OrgName == "@owner" { + if orgName == "" { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) } } - host := ghinstance.OverridableDefault() - if opts.OrgName == "@owner" { - opts.OrgName = baseRepo.RepoOwner() - host = baseRepo.RepoHost() - } - var pk *PubKey if opts.OrgName != "" { - pk, err = getOrgPublicKey(client, host, opts.OrgName) + pk, err = getOrgPublicKey(client, opts.OrgName) } else { pk, err = getRepoPubKey(client, baseRepo) } @@ -155,7 +149,7 @@ func setRun(opts *SetOptions) error { encoded := base64.StdEncoding.EncodeToString(eBody) if opts.OrgName != "" { - err = putOrgSecret(client, pk, host, *opts, encoded) + err = putOrgSecret(client, pk, *opts, encoded) } else { err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 32d0d50da..49621c5d2 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -28,12 +28,12 @@ func TestNewCmdSet(t *testing.T) { }{ { name: "invalid visibility", - cli: "cool_secret --org -v'mistyVeil'", + cli: "cool_secret --org coolOrg -v'mistyVeil'", wantsErr: true, }, { name: "invalid visibility", - cli: "cool_secret --org -v'selected'", + cli: "cool_secret --org coolOrg -v'selected'", wantsErr: true, }, { @@ -58,8 +58,8 @@ func TestNewCmdSet(t *testing.T) { wantsErr: true, }, { - name: "explicit org with selected repo", - cli: "--org=coolOrg -vselected -rcoolRepo cool_secret", + name: "org with selected repo", + cli: "-ocoolOrg -vselected -rcoolRepo cool_secret", wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisSelected, @@ -69,7 +69,7 @@ func TestNewCmdSet(t *testing.T) { }, }, { - name: "explicit org with selected repos", + name: "org with selected repos", cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, wants: SetOptions{ SecretName: "cool_secret", @@ -89,24 +89,14 @@ func TestNewCmdSet(t *testing.T) { OrgName: "", }, }, - { - name: "implicit org", - cli: `cool_secret --org -b"@cool.json"`, - wants: SetOptions{ - SecretName: "cool_secret", - Visibility: shared.VisPrivate, - Body: "@cool.json", - OrgName: "@owner", - }, - }, { name: "vis all", - cli: `cool_secret --org -b"@cool.json" -vall`, + cli: `cool_secret --org coolOrg -b"@cool.json" -vall`, wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.VisAll, Body: "@cool.json", - OrgName: "@owner", + OrgName: "coolOrg", }, }, { @@ -210,19 +200,12 @@ func Test_setRun_org(t *testing.T) { wantRepositories []int }{ { - name: "explicit org name", + name: "all vis", opts: &SetOptions{ OrgName: "UmbrellaCorporation", Visibility: shared.VisAll, }, }, - { - name: "implicit org name", - opts: &SetOptions{ - OrgName: "@owner", - Visibility: shared.VisPrivate, - }, - }, { name: "selected visibility", opts: &SetOptions{ @@ -238,11 +221,7 @@ func Test_setRun_org(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - impliedOrgName := "NeoUmbrella" orgName := tt.opts.OrgName - if orgName == "@owner" { - orgName = impliedOrgName - } reg.Register(httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)), @@ -260,7 +239,7 @@ func Test_setRun_org(t *testing.T) { io, _, _, _ := iostreams.Test() tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName(fmt.Sprintf("%s/repo", impliedOrgName)) + return ghrepo.FromFullName("owner/repo") } tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil From 408d5c6d9683d533455d24030b0ee14b85ae0dc5 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 13:23:35 -0800 Subject: [PATCH 080/129] no space in usage placeholder --- pkg/cmd/secret/remove/remove.go | 2 +- pkg/cmd/secret/set/set.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index ba26353ab..d3e98ba0d 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -28,7 +28,7 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "remove ", + Use: "remove ", Short: "Remove an organization or repository secret", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index f15e216e2..fcca3df74 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -41,7 +41,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command } cmd := &cobra.Command{ - Use: "set ", + Use: "set ", Short: "Create or update secrets", Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.", Example: heredoc.Doc(` From 2e6639fe78043983989e8e77a2822174243abf6f Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 14:59:37 -0800 Subject: [PATCH 081/129] show number of selected repositories in secret list --- pkg/cmd/secret/list/list.go | 56 +++++++++++++++++++++++++------- pkg/cmd/secret/list/list_test.go | 21 +++++++----- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index e1c8e32f1..cf2cd3ea0 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -3,6 +3,7 @@ package list import ( "fmt" "net/http" + "net/url" "strings" "time" @@ -69,7 +70,7 @@ func listRun(opts *ListOptions) error { } } - var secrets []Secret + var secrets []*Secret if orgName == "" { secrets, err = getRepoSecrets(client, baseRepo) } else { @@ -90,7 +91,7 @@ func listRun(opts *ListOptions) error { tp.AddField(updatedAt, nil, nil) if secret.Visibility != "" { if opts.IO.IsStdoutTTY() { - tp.AddField(fmtVisibility(secret), nil, nil) + tp.AddField(fmtVisibility(*secret), nil, nil) } else { tp.AddField(strings.ToUpper(secret.Visibility), nil, nil) } @@ -107,9 +108,11 @@ func listRun(opts *ListOptions) error { } type Secret struct { - Name string - UpdatedAt time.Time `json:"updated_at"` - Visibility string + Name string + UpdatedAt time.Time `json:"updated_at"` + Visibility string + SelectedReposURL string `json:"selected_repositories_url"` + NumSelectedRepos int } func fmtVisibility(s Secret) string { @@ -119,27 +122,56 @@ func fmtVisibility(s Secret) string { case shared.VisPrivate: return "Visible to private repositories" case shared.VisSelected: - // TODO print how many? print which ones? - return "Visible to selected repositories" + if s.NumSelectedRepos == 1 { + return "Visible to 1 selected repository" + } else { + return fmt.Sprintf("Visible to %d selected repositories", s.NumSelectedRepos) + } } return "" } -func getOrgSecrets(client *api.Client, orgName string) ([]Secret, error) { +func getOrgSecrets(client *api.Client, orgName string) ([]*Secret, error) { host := ghinstance.OverridableDefault() - return getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) + secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) + if err != nil { + return nil, err + } + + type responseData struct { + TotalCount int `json:"total_count"` + } + + for _, secret := range secrets { + if secret.SelectedReposURL == "" { + continue + } + u, err := url.Parse(secret.SelectedReposURL) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) + } + + var result responseData + err = client.REST(u.Host, "GET", u.Path[1:], nil, &result) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) + } + secret.NumSelectedRepos = result.TotalCount + } + + return secrets, nil } -func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]Secret, error) { +func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]*Secret, error) { return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets", ghrepo.FullName(repo))) } type secretsPayload struct { - Secrets []Secret + Secrets []*Secret } -func getSecrets(client *api.Client, host, path string) ([]Secret, error) { +func getSecrets(client *api.Client, host, path string) ([]*Secret, error) { result := secretsPayload{} err := client.REST(host, "GET", path, nil, &result) diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 960680455..8b3e6affb 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -68,8 +68,6 @@ func Test_NewCmdList(t *testing.T) { } } -// TODO run tests - func Test_listRun(t *testing.T) { tests := []struct { name string @@ -106,7 +104,7 @@ func Test_listRun(t *testing.T) { wantOut: []string{ "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", - "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to 2 selected repositories", }, }, { @@ -132,7 +130,7 @@ func Test_listRun(t *testing.T) { t2, _ := time.Parse("2006-01-02", "1975-11-30") path := "repos/owner/repo/actions/secrets" payload := secretsPayload{} - payload.Secrets = []Secret{ + payload.Secrets = []*Secret{ { Name: "SECRET_ONE", UpdatedAt: t0, @@ -147,7 +145,7 @@ func Test_listRun(t *testing.T) { }, } if tt.opts.OrgName != "" { - payload.Secrets = []Secret{ + payload.Secrets = []*Secret{ { Name: "SECRET_ONE", UpdatedAt: t0, @@ -159,12 +157,19 @@ func Test_listRun(t *testing.T) { Visibility: shared.VisPrivate, }, { - Name: "SECRET_THREE", - UpdatedAt: t2, - Visibility: shared.VisSelected, + Name: "SECRET_THREE", + UpdatedAt: t2, + Visibility: shared.VisSelected, + SelectedReposURL: fmt.Sprintf("https://api.github.com/orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName), }, } path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) + + reg.Register( + httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName)), + httpmock.JSONResponse(struct { + TotalCount int `json:"total_count"` + }{2})) } reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) From e1e838c281fdeedd42c0e234dfaab48783feb182 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 15:04:56 -0800 Subject: [PATCH 082/129] more explicit success message --- pkg/cmd/secret/remove/remove.go | 12 +++++++++--- pkg/cmd/secret/set/set.go | 13 +++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index d3e98ba0d..000b71ae4 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -67,10 +67,10 @@ func removeRun(opts *RemoveOptions) error { } var path string - if opts.OrgName == "" { + if orgName == "" { path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) } else { - path = fmt.Sprintf("orgs/%s/actions/secrets/%s", opts.OrgName, opts.SecretName) + path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName) } host := ghinstance.OverridableDefault() @@ -81,7 +81,13 @@ func removeRun(opts *RemoveOptions) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s Removed secret %s\n", cs.SuccessIcon(), opts.SecretName) + if orgName == "" { + fmt.Fprintf(opts.IO.Out, + "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo)) + } else { + fmt.Fprintf(opts.IO.Out, + "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, orgName) + } } return nil diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index fcca3df74..77ceee6dc 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -132,8 +132,8 @@ func setRun(opts *SetOptions) error { } var pk *PubKey - if opts.OrgName != "" { - pk, err = getOrgPublicKey(client, opts.OrgName) + if orgName != "" { + pk, err = getOrgPublicKey(client, orgName) } else { pk, err = getRepoPubKey(client, baseRepo) } @@ -148,7 +148,7 @@ func setRun(opts *SetOptions) error { encoded := base64.StdEncoding.EncodeToString(eBody) - if opts.OrgName != "" { + if orgName != "" { err = putOrgSecret(client, pk, *opts, encoded) } else { err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) @@ -159,7 +159,12 @@ func setRun(opts *SetOptions) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s Set secret %s\n", cs.SuccessIcon(), opts.SecretName) + + if orgName == "" { + fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo)) + } else { + fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, orgName) + } } return nil From 33063511624892aaab68a39a7a45b8a7a391168c Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 15:11:08 -0800 Subject: [PATCH 083/129] use type for Visibility --- pkg/cmd/secret/list/list.go | 10 +++++----- pkg/cmd/secret/list/list_test.go | 6 +++--- pkg/cmd/secret/set/http.go | 2 +- pkg/cmd/secret/set/set.go | 6 +++--- pkg/cmd/secret/set/set_test.go | 14 +++++++------- pkg/cmd/secret/shared/shared.go | 8 +++++--- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index cf2cd3ea0..792e5fc82 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -93,7 +93,7 @@ func listRun(opts *ListOptions) error { if opts.IO.IsStdoutTTY() { tp.AddField(fmtVisibility(*secret), nil, nil) } else { - tp.AddField(strings.ToUpper(secret.Visibility), nil, nil) + tp.AddField(strings.ToUpper(string(secret.Visibility)), nil, nil) } } tp.EndRow() @@ -110,18 +110,18 @@ func listRun(opts *ListOptions) error { type Secret struct { Name string UpdatedAt time.Time `json:"updated_at"` - Visibility string + Visibility shared.Visibility SelectedReposURL string `json:"selected_repositories_url"` NumSelectedRepos int } func fmtVisibility(s Secret) string { switch s.Visibility { - case shared.VisAll: + case shared.All: return "Visible to all repositories" - case shared.VisPrivate: + case shared.Private: return "Visible to private repositories" - case shared.VisSelected: + case shared.Selected: if s.NumSelectedRepos == 1 { return "Visible to 1 selected repository" } else { diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 8b3e6affb..601c13c27 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -149,17 +149,17 @@ func Test_listRun(t *testing.T) { { Name: "SECRET_ONE", UpdatedAt: t0, - Visibility: shared.VisAll, + Visibility: shared.All, }, { Name: "SECRET_TWO", UpdatedAt: t1, - Visibility: shared.VisPrivate, + Visibility: shared.Private, }, { Name: "SECRET_THREE", UpdatedAt: t2, - Visibility: shared.VisSelected, + Visibility: shared.Selected, SelectedReposURL: fmt.Sprintf("https://api.github.com/orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName), }, } diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index 303ace1b9..b8d80974e 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -74,7 +74,7 @@ func putOrgSecret(client *api.Client, pk *PubKey, opts SetOptions, eValue string var repositoryIDs []int var err error - if orgName != "" && visibility == shared.VisSelected { + if orgName != "" && visibility == shared.Selected { repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames) if err != nil { return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 77ceee6dc..47dcf7301 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -78,18 +78,18 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command "--visibility not supported for repository secrets; did you mean to pass --org?")} } - if opts.Visibility != shared.VisAll && opts.Visibility != shared.VisPrivate && opts.Visibility != shared.VisSelected { + if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected { return &cmdutil.FlagError{Err: errors.New( "--visibility must be one of `all`, `private`, or `selected`")} } } - if cmd.Flags().Changed("repos") && opts.Visibility != shared.VisSelected { + if cmd.Flags().Changed("repos") && opts.Visibility != shared.Selected { return &cmdutil.FlagError{Err: errors.New( "--repos only supported when --visibility='selected'")} } - if opts.Visibility == shared.VisSelected && len(opts.RepositoryNames) == 0 { + if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 { return &cmdutil.FlagError{Err: errors.New( "--repos flag required when --visibility='selected'")} } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 49621c5d2..f422dbe9c 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -62,7 +62,7 @@ func TestNewCmdSet(t *testing.T) { cli: "-ocoolOrg -vselected -rcoolRepo cool_secret", wants: SetOptions{ SecretName: "cool_secret", - Visibility: shared.VisSelected, + Visibility: shared.Selected, RepositoryNames: []string{"coolRepo"}, Body: "-", OrgName: "coolOrg", @@ -73,7 +73,7 @@ func TestNewCmdSet(t *testing.T) { cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, wants: SetOptions{ SecretName: "cool_secret", - Visibility: shared.VisSelected, + Visibility: shared.Selected, RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, Body: "-", OrgName: "coolOrg", @@ -84,7 +84,7 @@ func TestNewCmdSet(t *testing.T) { cli: `cool_secret -b"a secret"`, wants: SetOptions{ SecretName: "cool_secret", - Visibility: shared.VisPrivate, + Visibility: shared.Private, Body: "a secret", OrgName: "", }, @@ -94,7 +94,7 @@ func TestNewCmdSet(t *testing.T) { cli: `cool_secret --org coolOrg -b"@cool.json" -vall`, wants: SetOptions{ SecretName: "cool_secret", - Visibility: shared.VisAll, + Visibility: shared.All, Body: "@cool.json", OrgName: "coolOrg", }, @@ -196,21 +196,21 @@ func Test_setRun_org(t *testing.T) { tests := []struct { name string opts *SetOptions - wantVisibility string + wantVisibility shared.Visibility wantRepositories []int }{ { name: "all vis", opts: &SetOptions{ OrgName: "UmbrellaCorporation", - Visibility: shared.VisAll, + Visibility: shared.All, }, }, { name: "selected visibility", opts: &SetOptions{ OrgName: "UmbrellaCorporation", - Visibility: shared.VisSelected, + Visibility: shared.Selected, RepositoryNames: []string{"birkin", "wesker"}, }, wantRepositories: []int{1, 2}, diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go index 7e18b1298..4f58dd971 100644 --- a/pkg/cmd/secret/shared/shared.go +++ b/pkg/cmd/secret/shared/shared.go @@ -1,7 +1,9 @@ package shared +type Visibility string + const ( - VisAll = "all" - VisPrivate = "private" - VisSelected = "selected" + All = "all" + Private = "private" + Selected = "selected" ) From a5a043c5a59e5dee35e3d09bc991dab00387a5d5 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Dec 2020 15:24:09 -0800 Subject: [PATCH 084/129] automatically set vis when just -r passed --- pkg/cmd/secret/set/set.go | 20 ++++++++++++-------- pkg/cmd/secret/set/set_test.go | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 47dcf7301..ac042ca1b 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -82,16 +82,20 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return &cmdutil.FlagError{Err: errors.New( "--visibility must be one of `all`, `private`, or `selected`")} } - } - if cmd.Flags().Changed("repos") && opts.Visibility != shared.Selected { - return &cmdutil.FlagError{Err: errors.New( - "--repos only supported when --visibility='selected'")} - } + if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") { + return &cmdutil.FlagError{Err: errors.New( + "--repos only supported when --visibility='selected'")} + } - if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 { - return &cmdutil.FlagError{Err: errors.New( - "--repos flag required when --visibility='selected'")} + if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") { + return &cmdutil.FlagError{Err: errors.New( + "--repos flag required when --visibility='selected'")} + } + } else { + if cmd.Flags().Changed("repos") { + opts.Visibility = shared.Selected + } } if runF != nil { diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index f422dbe9c..ce193e365 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -36,6 +36,11 @@ func TestNewCmdSet(t *testing.T) { cli: "cool_secret --org coolOrg -v'selected'", wantsErr: true, }, + { + name: "repos with wrong vis", + cli: "cool_secret --org coolOrg -v'private' -rcoolRepo", + wantsErr: true, + }, { name: "no name", cli: "", @@ -57,6 +62,17 @@ func TestNewCmdSet(t *testing.T) { cli: "cool_secret -vall", wantsErr: true, }, + { + name: "repos without vis", + cli: "cool_secret --org coolOrg -rcoolRepo", + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo"}, + Body: "-", + OrgName: "coolOrg", + }, + }, { name: "org with selected repo", cli: "-ocoolOrg -vselected -rcoolRepo cool_secret", From 1fb542250ad7cb5c25660165ad6d93735db7de5e Mon Sep 17 00:00:00 2001 From: Guangyuan Yang Date: Mon, 14 Dec 2020 13:51:36 +0800 Subject: [PATCH 085/129] Document installation instructions for FreeBSD --- docs/install_linux.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 697bfb8f8..4fe314777 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -1,4 +1,4 @@ -# Installing gh on Linux +# Installing gh on Linux and FreeBSD Packages downloaded from https://cli.github.com or from https://github.com/cli/cli/releases are considered official binaries. We focus on popular Linux distros and @@ -101,6 +101,20 @@ Android 7+ users can install via [Termux](https://wiki.termux.com/wiki/Main_Page pkg install gh ``` +### FreeBSD + +FreeBSD users can install from the [ports collection](https://www.freshports.org/devel/gh/): + +```bash +cd /usr/ports/devel/gh/ && make install clean +``` + +Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)): + +```bash +pkg install gh +``` + ### Gentoo Gentoo Linux users can install from the [main portage tree](https://packages.gentoo.org/packages/dev-util/github-cli): From 352cde0563eed40ecdd98cd1c8c868250a62f6ce Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 14 Dec 2020 10:44:26 -0800 Subject: [PATCH 086/129] do not process filename arguments --- pkg/cmd/secret/set/set.go | 30 +++++++------------- pkg/cmd/secret/set/set_test.go | 52 ++++++++++------------------------ 2 files changed, 25 insertions(+), 57 deletions(-) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index ac042ca1b..1a05d93cc 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -45,12 +45,11 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command Short: "Create or update secrets", Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.", Example: heredoc.Doc(` - $ cat SECRET.txt | gh secret set NEW_SECRET - $ gh secret set NEW_SECRET -b"some literal value" - $ gh secret set NEW_SECRET -b"@file.json" - $ gh secret set ORG_SECRET --org - $ gh secret set ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3" - $ gh secret set ORG_SECRET --org=anotherOrg --visibility="all" + $ gh secret set FROM_FLAG -b"some literal value" + $ gh secret set FROM_ENV -b"${ENV_VALUE}" + $ gh secret set FROM_FILE < file.json + $ gh secret set ORG_SECRET -bval --org=anOrg --visibility=all + $ gh secret set ORG_SECRET -bval --org=anOrg --repos="repo1,repo2,repo3" `), Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { @@ -108,7 +107,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") - cmd.Flags().StringVarP(&opts.Body, "body", "b", "-", "Provide either a literal string or a file path; prepend file paths with an @. Reads from STDIN if not provided.") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.") return cmd } @@ -196,23 +195,14 @@ func validSecretName(name string) error { return nil } -func getBody(opts *SetOptions) (body []byte, err error) { - if opts.Body == "-" { - body, err = ioutil.ReadAll(opts.IO.In) +func getBody(opts *SetOptions) ([]byte, error) { + if opts.Body == "" { + body, err := ioutil.ReadAll(opts.IO.In) if err != nil { return nil, fmt.Errorf("failed to read from STDIN: %w", err) } - return - } - - if strings.HasPrefix(opts.Body, "@") { - body, err = opts.IO.ReadUserFile(opts.Body[1:]) - if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", opts.Body[1:], err) - } - - return + return body, nil } return []byte(opts.Body), nil diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index ce193e365..4536205a1 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io/ioutil" "net/http" - "os" "testing" "github.com/cli/cli/internal/ghrepo" @@ -64,34 +63,34 @@ func TestNewCmdSet(t *testing.T) { }, { name: "repos without vis", - cli: "cool_secret --org coolOrg -rcoolRepo", + cli: "cool_secret -bs --org coolOrg -rcoolRepo", wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.Selected, RepositoryNames: []string{"coolRepo"}, - Body: "-", + Body: "s", OrgName: "coolOrg", }, }, { name: "org with selected repo", - cli: "-ocoolOrg -vselected -rcoolRepo cool_secret", + cli: "-ocoolOrg -bs -vselected -rcoolRepo cool_secret", wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.Selected, RepositoryNames: []string{"coolRepo"}, - Body: "-", + Body: "s", OrgName: "coolOrg", }, }, { name: "org with selected repos", - cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, + cli: `--org=coolOrg -bs -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.Selected, RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, - Body: "-", + Body: "s", OrgName: "coolOrg", }, }, @@ -107,11 +106,11 @@ func TestNewCmdSet(t *testing.T) { }, { name: "vis all", - cli: `cool_secret --org coolOrg -b"@cool.json" -vall`, + cli: `cool_secret --org coolOrg -b"cool" -vall`, wants: SetOptions{ SecretName: "cool_secret", Visibility: shared.All, - Body: "@cool.json", + Body: "cool", OrgName: "coolOrg", }, }, @@ -286,11 +285,10 @@ func Test_setRun_org(t *testing.T) { func Test_getBody(t *testing.T) { tests := []struct { - name string - bodyArg string - want string - stdin string - fromFile bool + name string + bodyArg string + want string + stdin string }{ { name: "literal value", @@ -298,15 +296,9 @@ func Test_getBody(t *testing.T) { want: "a secret", }, { - name: "from stdin", - bodyArg: "-", - want: "a secret", - stdin: "a secret", - }, - { - name: "from file", - fromFile: true, - want: "a secret from a file", + name: "from stdin", + want: "a secret", + stdin: "a secret", }, } @@ -319,15 +311,6 @@ func Test_getBody(t *testing.T) { _, err := stdin.WriteString(tt.stdin) assert.NoError(t, err) - if tt.fromFile { - dir := os.TempDir() - tmpfile, err := ioutil.TempFile(dir, "testfile*") - assert.NoError(t, err) - _, err = tmpfile.WriteString(tt.want) - assert.NoError(t, err) - tt.bodyArg = fmt.Sprintf("@%s", tmpfile.Name()) - } - body, err := getBody(&SetOptions{ Body: tt.bodyArg, IO: io, @@ -335,11 +318,6 @@ func Test_getBody(t *testing.T) { assert.NoError(t, err) assert.Equal(t, string(body), tt.want) - }) - } - } - -// TODO test updating org secret's repo lists From 4a30800eb934a74da62f3e4f006b79c375f136e6 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 14 Dec 2020 14:05:50 -0800 Subject: [PATCH 087/129] add arm build for raspberry pi --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index f04e7f7f2..0f0d64ae2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,7 +24,7 @@ builds: - <<: *build_defaults id: linux goos: [linux] - goarch: [386, amd64, arm64] + goarch: [386, arm, amd64, arm64] - <<: *build_defaults id: windows From 8b197ac0bc8f169862d296350554ec875192eb05 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 14 Dec 2020 14:33:54 -0800 Subject: [PATCH 088/129] package for armhf --- script/distributions | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/script/distributions b/script/distributions index 5af84b6b3..20308bbf6 100644 --- a/script/distributions +++ b/script/distributions @@ -1,7 +1,7 @@ Origin: gh Label: gh Codename: stable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian stable repo SignWith: C99B11DEB97541F0 @@ -9,7 +9,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: oldstable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian oldstable repo SignWith: C99B11DEB97541F0 @@ -17,7 +17,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: testing -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian testing repo SignWith: C99B11DEB97541F0 @@ -25,7 +25,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: unstable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian unstable repo SignWith: C99B11DEB97541F0 @@ -33,7 +33,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: buster -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian buster repo SignWith: C99B11DEB97541F0 @@ -41,7 +41,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: bullseye -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian bullseye repo SignWith: C99B11DEB97541F0 @@ -49,7 +49,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: stretch -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian stretch repo SignWith: C99B11DEB97541F0 @@ -57,7 +57,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: jessie -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian jessie repo SignWith: C99B11DEB97541F0 @@ -65,7 +65,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: focal -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu focal repo SignWith: C99B11DEB97541F0 @@ -74,7 +74,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: precise -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu precise repo SignWith: C99B11DEB97541F0 @@ -83,7 +83,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: bionic -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu bionic repo SignWith: C99B11DEB97541F0 @@ -92,7 +92,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: trusty -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu trusty repo SignWith: C99B11DEB97541F0 @@ -101,7 +101,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: xenial -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu xenial repo SignWith: C99B11DEB97541F0 @@ -110,7 +110,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: groovy -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu groovy repo SignWith: C99B11DEB97541F0 @@ -119,7 +119,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: eoan -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu eoan repo SignWith: C99B11DEB97541F0 @@ -128,7 +128,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: disco -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu disco repo SignWith: C99B11DEB97541F0 @@ -137,7 +137,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: cosmic -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu cosmic repo SignWith: C99B11DEB97541F0 From b8522f683c571c70c9672f03f5dff0480fef5c42 Mon Sep 17 00:00:00 2001 From: Emily Roberts Date: Tue, 15 Dec 2020 01:17:44 -0700 Subject: [PATCH 089/129] Add openSUSE distro package install instructions Added the instructions to install the GitHub CLI from the openSUSE distribution repositories --- docs/install_linux.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/install_linux.md b/docs/install_linux.md index 4fe314777..37cad108e 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -145,6 +145,12 @@ Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?sho ```bash nix-env -iA nixos.gitAndTools.gh ``` +### openSUSE +openSUSE Tumbleweed users can install from the [offical distribution repo](https://software.opensuse.org/package/gh): +```bash +sudo zypper in gh +``` +This package is not available on the current release of openSUSE Leap ### Snaps From cba15d0109bd1e3a650879935bacc7d05e36b62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 15 Dec 2020 15:16:33 +0100 Subject: [PATCH 090/129] Clarify openSUSE Tumbleweed instructions --- docs/install_linux.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 37cad108e..d51f35d09 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -145,12 +145,13 @@ Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?sho ```bash nix-env -iA nixos.gitAndTools.gh ``` -### openSUSE + +### openSUSE Tumbleweed + openSUSE Tumbleweed users can install from the [offical distribution repo](https://software.opensuse.org/package/gh): ```bash sudo zypper in gh ``` -This package is not available on the current release of openSUSE Leap ### Snaps From dc8698ee46b6fa2b0311a1352e429cbbf9e2599d Mon Sep 17 00:00:00 2001 From: Alisson Santos Date: Mon, 19 Oct 2020 15:09:48 +0200 Subject: [PATCH 091/129] Make ssh parser to parse included config files --- git/ssh_config.go | 129 ++++++++++++++++++++++------------ git/ssh_config_test.go | 100 +++++++++++++++++++++++--- git/testdata/included.conf | 2 + git/testdata/ssh_config1.conf | 2 + git/testdata/ssh_config2.conf | 2 + git/testdata/ssh_config3.conf | 1 + 6 files changed, 181 insertions(+), 55 deletions(-) create mode 100644 git/testdata/included.conf create mode 100644 git/testdata/ssh_config1.conf create mode 100644 git/testdata/ssh_config2.conf create mode 100644 git/testdata/ssh_config3.conf diff --git a/git/ssh_config.go b/git/ssh_config.go index 287298cd9..b65d8c895 100644 --- a/git/ssh_config.go +++ b/git/ssh_config.go @@ -2,7 +2,6 @@ package git import ( "bufio" - "io" "net/url" "os" "path/filepath" @@ -13,12 +12,10 @@ import ( ) var ( - sshHostRE, sshTokenRE *regexp.Regexp ) func init() { - sshHostRE = regexp.MustCompile("(?i)^[ \t]*(host|hostname)[ \t]+(.+)$") sshTokenRE = regexp.MustCompile(`%[%h]`) } @@ -45,6 +42,88 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL { } } +type parser struct { + aliasMap SSHAliasMap +} + +func (p *parser) read(fileName string) error { + file, err := os.Open(fileName) + if err != nil { + return err + } + defer file.Close() + + hosts := []string{"*"} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + + if len(fields) < 2 { + continue + } + + directive, params := fields[0], fields[1:] + switch { + case strings.EqualFold(directive, "Host"): + hosts = params + case strings.EqualFold(directive, "Hostname"): + for _, host := range hosts { + for _, name := range params { + p.aliasMap[host] = sshExpandTokens(name, host) + } + } + case strings.EqualFold(directive, "Include"): + for _, path := range absolutePaths(fileName, params) { + fileNames, err := filepath.Glob(path) + if err != nil { + continue + } + + for _, fileName := range fileNames { + _ = p.read(fileName) + } + } + } + } + + return scanner.Err() +} + +func isSystem(path string) bool { + return strings.HasPrefix(path, "/etc/ssh") +} + +func absolutePaths(parentFile string, paths []string) []string { + absPaths := make([]string, len(paths)) + + for i, path := range paths { + switch { + case filepath.IsAbs(path): + absPaths[i] = path + case strings.HasPrefix(path, "~"): + absPaths[i], _ = homedir.Expand(path) + case isSystem(parentFile): + absPaths[i] = filepath.Join("/etc", "ssh", path) + default: + dir, _ := homedir.Dir() + absPaths[i] = filepath.Join(dir, ".ssh", path) + } + } + + return absPaths +} + +func parse(files ...string) SSHAliasMap { + p := parser{aliasMap: make(SSHAliasMap)} + + for _, file := range files { + _ = p.read(file) + } + + return p.aliasMap +} + // ParseSSHConfig constructs a map of SSH hostname aliases based on user and // system configuration files func ParseSSHConfig() SSHAliasMap { @@ -57,49 +136,7 @@ func ParseSSHConfig() SSHAliasMap { configFiles = append([]string{userConfig}, configFiles...) } - openFiles := make([]io.Reader, 0, len(configFiles)) - for _, file := range configFiles { - f, err := os.Open(file) - if err != nil { - continue - } - defer f.Close() - openFiles = append(openFiles, f) - } - return sshParse(openFiles...) -} - -func sshParse(r ...io.Reader) SSHAliasMap { - config := make(SSHAliasMap) - for _, file := range r { - _ = sshParseConfig(config, file) - } - return config -} - -func sshParseConfig(c SSHAliasMap, file io.Reader) error { - hosts := []string{"*"} - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - match := sshHostRE.FindStringSubmatch(line) - if match == nil { - continue - } - - names := strings.Fields(match[2]) - if strings.EqualFold(match[1], "host") { - hosts = names - } else { - for _, host := range hosts { - for _, name := range names { - c[host] = sshExpandTokens(name, host) - } - } - } - } - - return scanner.Err() + return parse(configFiles...) } func sshExpandTokens(text, host string) string { diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go index 7aafc5b21..55874fb6e 100644 --- a/git/ssh_config_test.go +++ b/git/ssh_config_test.go @@ -1,10 +1,15 @@ package git import ( + "fmt" + "io/ioutil" "net/url" + "os" + "path/filepath" "reflect" - "strings" "testing" + + "github.com/mitchellh/go-homedir" ) // TODO: extract assertion helpers into a shared package @@ -15,19 +20,96 @@ func eq(t *testing.T, got interface{}, expected interface{}) { } } -func Test_sshParse(t *testing.T) { - m := sshParse(strings.NewReader(` - Host foo bar - HostName example.com - `), strings.NewReader(` - Host bar baz - hostname %%%h.net%% - `)) +func createTempFile(t *testing.T, prefix string) *os.File { + t.Helper() + + dir, err := homedir.Dir() + if err != nil { + t.Errorf("Could not find homedir: %s", err) + } + + tempFile, err := ioutil.TempFile(filepath.Join(dir, ".ssh"), prefix) + if err != nil { + t.Errorf("Could create a temp file: %s", err) + } + + t.Cleanup(func() { + tempFile.Close() + os.Remove(tempFile.Name()) + }) + + return tempFile +} + +func Test_parse(t *testing.T) { + includedTempFile := createTempFile(t, "included") + includedConfigFile := ` +Host webapp + HostName webapp.example.com + ` + fmt.Fprint(includedTempFile, includedConfigFile) + + m := parse( + "testdata/ssh_config1.conf", + "testdata/ssh_config2.conf", + "testdata/ssh_config3.conf", + ) + eq(t, m["foo"], "example.com") eq(t, m["bar"], "%bar.net%") eq(t, m["nonexistent"], "") } +func Test_absolutePaths(t *testing.T) { + dir, err := homedir.Dir() + if err != nil { + t.Errorf("Could not find homedir: %s", err) + } + + tests := map[string]struct { + parentFile string + Input []string + Want []string + }{ + "absolute path": { + parentFile: "/etc/ssh/ssh_config", + Input: []string{"/etc/ssh/config"}, + Want: []string{"/etc/ssh/config"}, + }, + "system relative path": { + parentFile: "/etc/ssh/config", + Input: []string{"configs/*.conf"}, + Want: []string{"/etc/ssh/configs/*.conf"}, + }, + "user relative path": { + parentFile: filepath.Join(dir, ".ssh", "ssh_config"), + Input: []string{"configs/*.conf"}, + Want: []string{filepath.Join(dir, ".ssh", "configs/*.conf")}, + }, + "shell-like ~ rerefence": { + parentFile: filepath.Join(dir, ".ssh", "ssh_config"), + Input: []string{"~/.ssh/*.conf"}, + Want: []string{filepath.Join(dir, ".ssh", "*.conf")}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + paths := absolutePaths(test.parentFile, test.Input) + + if len(paths) != len(test.Input) { + t.Errorf("Expected %d, got %d", len(test.Input), len(paths)) + } + + for i, path := range paths { + if path != test.Want[i] { + t.Errorf("Expected %q, got %q", test.Want[i], path) + } + } + }) + } +} + func Test_Translator(t *testing.T) { m := SSHAliasMap{ "gh": "github.com", diff --git a/git/testdata/included.conf b/git/testdata/included.conf new file mode 100644 index 000000000..2ded9d103 --- /dev/null +++ b/git/testdata/included.conf @@ -0,0 +1,2 @@ +Host webapp + HostName webapp.example.com diff --git a/git/testdata/ssh_config1.conf b/git/testdata/ssh_config1.conf new file mode 100644 index 000000000..7249b01fb --- /dev/null +++ b/git/testdata/ssh_config1.conf @@ -0,0 +1,2 @@ +Host foo bar + HostName example.com diff --git a/git/testdata/ssh_config2.conf b/git/testdata/ssh_config2.conf new file mode 100644 index 000000000..3884f3d15 --- /dev/null +++ b/git/testdata/ssh_config2.conf @@ -0,0 +1,2 @@ +Host bar baz +hostname %%%h.net%% diff --git a/git/testdata/ssh_config3.conf b/git/testdata/ssh_config3.conf new file mode 100644 index 000000000..7b55743ea --- /dev/null +++ b/git/testdata/ssh_config3.conf @@ -0,0 +1 @@ +Include ~/.ssh/included* From 935f6444ae330b8ba89fd048c4ef5ef145e5910f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 15 Dec 2020 15:02:49 +0100 Subject: [PATCH 092/129] Refactor ssh parser for format compatibility & testability - Per ssh_config(5), keywords and arguments may be separated by an `=` sign as well as whitespace. - When following the `Include` directive, skip directories that were returned as the result of globbing. - Respect the `Host` context when recursing into `Include`s - Avoid having tests read from the actual filesystem. - Avoid repeatedly looking up the home directory. --- git/remote_test.go | 13 ++- git/ssh_config.go | 134 +++++++++++++++------------ git/ssh_config_test.go | 164 ++++++++++++++++++---------------- git/testdata/included.conf | 2 - git/testdata/ssh_config1.conf | 2 - git/testdata/ssh_config2.conf | 2 - git/testdata/ssh_config3.conf | 1 - 7 files changed, 178 insertions(+), 140 deletions(-) delete mode 100644 git/testdata/included.conf delete mode 100644 git/testdata/ssh_config1.conf delete mode 100644 git/testdata/ssh_config2.conf delete mode 100644 git/testdata/ssh_config3.conf diff --git a/git/remote_test.go b/git/remote_test.go index 2e7d30cb6..e8c091653 100644 --- a/git/remote_test.go +++ b/git/remote_test.go @@ -1,6 +1,17 @@ package git -import "testing" +import ( + "reflect" + "testing" +) + +// TODO: extract assertion helpers into a shared package +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} func Test_parseRemotes(t *testing.T) { remoteList := []string{ diff --git a/git/ssh_config.go b/git/ssh_config.go index b65d8c895..317ff6059 100644 --- a/git/ssh_config.go +++ b/git/ssh_config.go @@ -2,6 +2,7 @@ package git import ( "bufio" + "io" "net/url" "os" "path/filepath" @@ -12,13 +13,10 @@ import ( ) var ( - sshTokenRE *regexp.Regexp + sshConfigLineRE = regexp.MustCompile(`\A\s*(?P[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P.+)`) + sshTokenRE = regexp.MustCompile(`%[%h]`) ) -func init() { - sshTokenRE = regexp.MustCompile(`%[%h]`) -} - // SSHAliasMap encapsulates the translation of SSH hostname aliases type SSHAliasMap map[string]string @@ -42,42 +40,75 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL { } } -type parser struct { +type sshParser struct { + homeDir string + aliasMap SSHAliasMap + hosts []string + + open func(string) (io.Reader, error) + glob func(string) ([]string, error) } -func (p *parser) read(fileName string) error { - file, err := os.Open(fileName) - if err != nil { - return err +func (p *sshParser) read(fileName string) error { + var file io.Reader + if p.open == nil { + f, err := os.Open(fileName) + if err != nil { + return err + } + defer f.Close() + file = f + } else { + var err error + file, err = p.open(fileName) + if err != nil { + return err + } + } + + if len(p.hosts) == 0 { + p.hosts = []string{"*"} } - defer file.Close() - hosts := []string{"*"} scanner := bufio.NewScanner(file) for scanner.Scan() { - line := scanner.Text() - fields := strings.Fields(line) - - if len(fields) < 2 { + m := sshConfigLineRE.FindStringSubmatch(scanner.Text()) + if len(m) < 3 { continue } - directive, params := fields[0], fields[1:] - switch { - case strings.EqualFold(directive, "Host"): - hosts = params - case strings.EqualFold(directive, "Hostname"): - for _, host := range hosts { - for _, name := range params { + keyword, arguments := strings.ToLower(m[1]), m[2] + switch keyword { + case "host": + p.hosts = strings.Fields(arguments) + case "hostname": + for _, host := range p.hosts { + for _, name := range strings.Fields(arguments) { + if p.aliasMap == nil { + p.aliasMap = make(SSHAliasMap) + } p.aliasMap[host] = sshExpandTokens(name, host) } } - case strings.EqualFold(directive, "Include"): - for _, path := range absolutePaths(fileName, params) { - fileNames, err := filepath.Glob(path) - if err != nil { - continue + case "include": + for _, arg := range strings.Fields(arguments) { + path := p.absolutePath(fileName, arg) + + var fileNames []string + if p.glob == nil { + paths, _ := filepath.Glob(path) + for _, p := range paths { + if s, err := os.Stat(p); err == nil && !s.IsDir() { + fileNames = append(fileNames, p) + } + } + } else { + var err error + fileNames, err = p.glob(path) + if err != nil { + continue + } } for _, fileName := range fileNames { @@ -90,38 +121,20 @@ func (p *parser) read(fileName string) error { return scanner.Err() } -func isSystem(path string) bool { - return strings.HasPrefix(path, "/etc/ssh") -} - -func absolutePaths(parentFile string, paths []string) []string { - absPaths := make([]string, len(paths)) - - for i, path := range paths { - switch { - case filepath.IsAbs(path): - absPaths[i] = path - case strings.HasPrefix(path, "~"): - absPaths[i], _ = homedir.Expand(path) - case isSystem(parentFile): - absPaths[i] = filepath.Join("/etc", "ssh", path) - default: - dir, _ := homedir.Dir() - absPaths[i] = filepath.Join(dir, ".ssh", path) - } +func (p *sshParser) absolutePath(parentFile, path string) string { + if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") { + return path } - return absPaths -} - -func parse(files ...string) SSHAliasMap { - p := parser{aliasMap: make(SSHAliasMap)} - - for _, file := range files { - _ = p.read(file) + if strings.HasPrefix(path, "~") { + return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~")) } - return p.aliasMap + if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") { + return filepath.Join("/etc/ssh", path) + } + + return filepath.Join(p.homeDir, ".ssh", path) } // ParseSSHConfig constructs a map of SSH hostname aliases based on user and @@ -131,12 +144,19 @@ func ParseSSHConfig() SSHAliasMap { "/etc/ssh_config", "/etc/ssh/ssh_config", } + + p := sshParser{} + if homedir, err := homedir.Dir(); err == nil { userConfig := filepath.Join(homedir, ".ssh", "config") configFiles = append([]string{userConfig}, configFiles...) + p.homeDir = homedir } - return parse(configFiles...) + for _, file := range configFiles { + _ = p.read(file) + } + return p.aliasMap } func sshExpandTokens(text, host string) string { diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go index 55874fb6e..f05ca303b 100644 --- a/git/ssh_config_test.go +++ b/git/ssh_config_test.go @@ -1,110 +1,124 @@ package git import ( + "bytes" "fmt" - "io/ioutil" + "io" "net/url" - "os" "path/filepath" - "reflect" "testing" - "github.com/mitchellh/go-homedir" + "github.com/MakeNowJust/heredoc" ) -// TODO: extract assertion helpers into a shared package -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) +func Test_sshParser_read(t *testing.T) { + testFiles := map[string]string{ + "/etc/ssh/config": heredoc.Doc(` + Include sites/* + `), + "/etc/ssh/sites/cfg1": heredoc.Doc(` + Host s1 + Hostname=site1.net + `), + "/etc/ssh/sites/cfg2": heredoc.Doc(` + Host s2 + Hostname = site2.net + `), + "HOME/.ssh/config": heredoc.Doc(` + Host * + Host gh gittyhubby + Hostname github.com + #Hostname example.com + Host ex + Include ex_config/* + `), + "HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(` + Hostname example.com + `), + } + globResults := map[string][]string{ + "/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"}, + "HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"}, + } + + p := &sshParser{ + homeDir: "HOME", + open: func(s string) (io.Reader, error) { + if contents, ok := testFiles[filepath.ToSlash(s)]; ok { + return bytes.NewBufferString(contents), nil + } else { + return nil, fmt.Errorf("no test file stub found: %q", s) + } + }, + glob: func(p string) ([]string, error) { + if results, ok := globResults[filepath.ToSlash(p)]; ok { + return results, nil + } else { + return nil, fmt.Errorf("no glob stubs found: %q", p) + } + }, + } + + if err := p.read("/etc/ssh/config"); err != nil { + t.Fatalf("read(global config) = %v", err) + } + if err := p.read("HOME/.ssh/config"); err != nil { + t.Fatalf("read(user config) = %v", err) + } + + if got := p.aliasMap["gh"]; got != "github.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got) + } + if got := p.aliasMap["gittyhubby"]; got != "github.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got) + } + if got := p.aliasMap["example.com"]; got != "" { + t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got) + } + if got := p.aliasMap["ex"]; got != "example.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got) + } + if got := p.aliasMap["s1"]; got != "site1.net" { + t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got) } } -func createTempFile(t *testing.T, prefix string) *os.File { - t.Helper() - - dir, err := homedir.Dir() - if err != nil { - t.Errorf("Could not find homedir: %s", err) - } - - tempFile, err := ioutil.TempFile(filepath.Join(dir, ".ssh"), prefix) - if err != nil { - t.Errorf("Could create a temp file: %s", err) - } - - t.Cleanup(func() { - tempFile.Close() - os.Remove(tempFile.Name()) - }) - - return tempFile -} - -func Test_parse(t *testing.T) { - includedTempFile := createTempFile(t, "included") - includedConfigFile := ` -Host webapp - HostName webapp.example.com - ` - fmt.Fprint(includedTempFile, includedConfigFile) - - m := parse( - "testdata/ssh_config1.conf", - "testdata/ssh_config2.conf", - "testdata/ssh_config3.conf", - ) - - eq(t, m["foo"], "example.com") - eq(t, m["bar"], "%bar.net%") - eq(t, m["nonexistent"], "") -} - -func Test_absolutePaths(t *testing.T) { - dir, err := homedir.Dir() - if err != nil { - t.Errorf("Could not find homedir: %s", err) - } +func Test_sshParser_absolutePath(t *testing.T) { + dir := "HOME" + p := &sshParser{homeDir: dir} tests := map[string]struct { parentFile string - Input []string - Want []string + arg string + want string + wantErr bool }{ "absolute path": { parentFile: "/etc/ssh/ssh_config", - Input: []string{"/etc/ssh/config"}, - Want: []string{"/etc/ssh/config"}, + arg: "/etc/ssh/config", + want: "/etc/ssh/config", }, "system relative path": { parentFile: "/etc/ssh/config", - Input: []string{"configs/*.conf"}, - Want: []string{"/etc/ssh/configs/*.conf"}, + arg: "configs/*.conf", + want: filepath.Join("/etc", "ssh", "configs", "*.conf"), }, "user relative path": { parentFile: filepath.Join(dir, ".ssh", "ssh_config"), - Input: []string{"configs/*.conf"}, - Want: []string{filepath.Join(dir, ".ssh", "configs/*.conf")}, + arg: "configs/*.conf", + want: filepath.Join(dir, ".ssh", "configs/*.conf"), }, "shell-like ~ rerefence": { parentFile: filepath.Join(dir, ".ssh", "ssh_config"), - Input: []string{"~/.ssh/*.conf"}, - Want: []string{filepath.Join(dir, ".ssh", "*.conf")}, + arg: "~/.ssh/*.conf", + want: filepath.Join(dir, ".ssh", "*.conf"), }, } - for name, test := range tests { + for name, tt := range tests { t.Run(name, func(t *testing.T) { - paths := absolutePaths(test.parentFile, test.Input) - - if len(paths) != len(test.Input) { - t.Errorf("Expected %d, got %d", len(test.Input), len(paths)) - } - - for i, path := range paths { - if path != test.Want[i] { - t.Errorf("Expected %q, got %q", test.Want[i], path) - } + if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want { + t.Errorf("absolutePath(): %q, wants %q", got, tt.want) } }) } diff --git a/git/testdata/included.conf b/git/testdata/included.conf deleted file mode 100644 index 2ded9d103..000000000 --- a/git/testdata/included.conf +++ /dev/null @@ -1,2 +0,0 @@ -Host webapp - HostName webapp.example.com diff --git a/git/testdata/ssh_config1.conf b/git/testdata/ssh_config1.conf deleted file mode 100644 index 7249b01fb..000000000 --- a/git/testdata/ssh_config1.conf +++ /dev/null @@ -1,2 +0,0 @@ -Host foo bar - HostName example.com diff --git a/git/testdata/ssh_config2.conf b/git/testdata/ssh_config2.conf deleted file mode 100644 index 3884f3d15..000000000 --- a/git/testdata/ssh_config2.conf +++ /dev/null @@ -1,2 +0,0 @@ -Host bar baz -hostname %%%h.net%% diff --git a/git/testdata/ssh_config3.conf b/git/testdata/ssh_config3.conf deleted file mode 100644 index 7b55743ea..000000000 --- a/git/testdata/ssh_config3.conf +++ /dev/null @@ -1 +0,0 @@ -Include ~/.ssh/included* From 0ec8c2e9edf969ab7968e6a6b8e2d86ee4504098 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 26 Nov 2020 11:00:27 -0300 Subject: [PATCH 093/129] Notify new releases only once per day --- update/update.go | 21 +++++++++++++++------ update/update_test.go | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/update/update.go b/update/update.go index bf89a12e8..a44f6da29 100644 --- a/update/update.go +++ b/update/update.go @@ -24,22 +24,26 @@ type StateEntry struct { // CheckForUpdate checks whether this software has had a newer release on GitHub func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { - latestRelease, err := getLatestReleaseInfo(client, stateFilePath, repo, currentVersion) + releaseInfo, err := getLatestReleaseInfo(client, stateFilePath, repo) if err != nil { return nil, err } - if versionGreaterThan(latestRelease.Version, currentVersion) { - return latestRelease, nil + if releaseInfo == nil { + return nil, nil + } + + if versionGreaterThan(releaseInfo.LatestRelease.Version, currentVersion) { + return &releaseInfo.LatestRelease, nil } return nil, nil } -func getLatestReleaseInfo(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { +func getLatestReleaseInfo(client *api.Client, stateFilePath, repo string) (*StateEntry, error) { stateEntry, err := getStateEntry(stateFilePath) if err == nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { - return &stateEntry.LatestRelease, nil + return nil, nil } var latestRelease ReleaseInfo @@ -53,7 +57,12 @@ func getLatestReleaseInfo(client *api.Client, stateFilePath, repo, currentVersio return nil, err } - return &latestRelease, nil + stateEntry, err = getStateEntry(stateFilePath) + if err != nil { + return nil, err + } + + return stateEntry, nil } func getStateEntry(stateFilePath string) (*StateEntry, error) { diff --git a/update/update_test.go b/update/update_test.go index 2fcb2d6ab..3ea6cb533 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -52,6 +52,9 @@ func TestCheckForUpdate(t *testing.T) { for _, s := range scenarios { t.Run(s.Name, func(t *testing.T) { + stateFilePath := tempFilePath() + defer os.Remove(stateFilePath) + http := &httpmock.Registry{} client := api.NewClient(api.ReplaceTripper(http)) http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{ @@ -59,7 +62,7 @@ func TestCheckForUpdate(t *testing.T) { "html_url": "%s" }`, s.LatestVersion, s.LatestURL))) - rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) + rel, err := CheckForUpdate(client, stateFilePath, "OWNER/REPO", s.CurrentVersion) if err != nil { t.Fatal(err) } @@ -93,10 +96,18 @@ func TestCheckForUpdate(t *testing.T) { } func tempFilePath() string { - file, err := ioutil.TempFile("", "") + content := []byte( + `checked_for_update_at: 2020-11-22T09:53:32.609610344-03:00 +latest_release: + version: v1.1.0 + url: https://github.com/cli/cli/releases/tag/v1.1.0`) + file, err := ioutil.TempFile("", "state*.yml") if err != nil { log.Fatal(err) } - os.Remove(file.Name()) + + if _, err := file.Write(content); err != nil { + log.Fatal(err) + } return file.Name() } From 42c97509ca18fd5057441ff13af9200751f6f47c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 1 Dec 2020 12:18:32 -0500 Subject: [PATCH 094/129] Simplify CheckForUpdate handling of state file --- update/update.go | 37 ++++++++++++++----------------------- update/update_test.go | 17 +++-------------- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/update/update.go b/update/update.go index a44f6da29..547ba5a20 100644 --- a/update/update.go +++ b/update/update.go @@ -24,45 +24,36 @@ type StateEntry struct { // CheckForUpdate checks whether this software has had a newer release on GitHub func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { - releaseInfo, err := getLatestReleaseInfo(client, stateFilePath, repo) + stateEntry, _ := getStateEntry(stateFilePath) + if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { + return nil, nil + } + + releaseInfo, err := getLatestReleaseInfo(client, repo) if err != nil { return nil, err } - if releaseInfo == nil { - return nil, nil + err = setStateEntry(stateFilePath, time.Now(), *releaseInfo) + if err != nil { + return nil, err } - if versionGreaterThan(releaseInfo.LatestRelease.Version, currentVersion) { - return &releaseInfo.LatestRelease, nil + if versionGreaterThan(releaseInfo.Version, currentVersion) { + return releaseInfo, nil } return nil, nil } -func getLatestReleaseInfo(client *api.Client, stateFilePath, repo string) (*StateEntry, error) { - stateEntry, err := getStateEntry(stateFilePath) - if err == nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { - return nil, nil - } - +func getLatestReleaseInfo(client *api.Client, repo string) (*ReleaseInfo, error) { var latestRelease ReleaseInfo - err = client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) + err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) if err != nil { return nil, err } - err = setStateEntry(stateFilePath, time.Now(), latestRelease) - if err != nil { - return nil, err - } - - stateEntry, err = getStateEntry(stateFilePath) - if err != nil { - return nil, err - } - - return stateEntry, nil + return &latestRelease, nil } func getStateEntry(stateFilePath string) (*StateEntry, error) { diff --git a/update/update_test.go b/update/update_test.go index 3ea6cb533..2fcb2d6ab 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -52,9 +52,6 @@ func TestCheckForUpdate(t *testing.T) { for _, s := range scenarios { t.Run(s.Name, func(t *testing.T) { - stateFilePath := tempFilePath() - defer os.Remove(stateFilePath) - http := &httpmock.Registry{} client := api.NewClient(api.ReplaceTripper(http)) http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{ @@ -62,7 +59,7 @@ func TestCheckForUpdate(t *testing.T) { "html_url": "%s" }`, s.LatestVersion, s.LatestURL))) - rel, err := CheckForUpdate(client, stateFilePath, "OWNER/REPO", s.CurrentVersion) + rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) if err != nil { t.Fatal(err) } @@ -96,18 +93,10 @@ func TestCheckForUpdate(t *testing.T) { } func tempFilePath() string { - content := []byte( - `checked_for_update_at: 2020-11-22T09:53:32.609610344-03:00 -latest_release: - version: v1.1.0 - url: https://github.com/cli/cli/releases/tag/v1.1.0`) - file, err := ioutil.TempFile("", "state*.yml") + file, err := ioutil.TempFile("", "") if err != nil { log.Fatal(err) } - - if _, err := file.Write(content); err != nil { - log.Fatal(err) - } + os.Remove(file.Name()) return file.Name() } From 2843ffff23eb85859aa46227089b185af6a7e235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 15 Dec 2020 16:09:08 +0100 Subject: [PATCH 095/129] Classify the `update` package as internal --- cmd/gh/main.go | 2 +- {update => internal/update}/update.go | 0 {update => internal/update}/update_test.go | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename {update => internal/update}/update.go (100%) rename {update => internal/update}/update_test.go (100%) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 61cbf7081..f1e72f1b5 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -16,11 +16,11 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/run" + "github.com/cli/cli/internal/update" "github.com/cli/cli/pkg/cmd/alias/expand" "github.com/cli/cli/pkg/cmd/factory" "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/update" "github.com/cli/cli/utils" "github.com/cli/safeexec" "github.com/mattn/go-colorable" diff --git a/update/update.go b/internal/update/update.go similarity index 100% rename from update/update.go rename to internal/update/update.go diff --git a/update/update_test.go b/internal/update/update_test.go similarity index 100% rename from update/update_test.go rename to internal/update/update_test.go From fd57835bb9d620bc06b5e682a7484442535912cc Mon Sep 17 00:00:00 2001 From: gunadhya <6939749+gunadhya@users.noreply.github.com> Date: Fri, 27 Nov 2020 16:20:19 +0530 Subject: [PATCH 096/129] Fix repo clone wiki --- api/queries_repo.go | 2 ++ pkg/cmd/repo/clone/clone.go | 16 ++++++++++++- pkg/cmd/repo/clone/clone_test.go | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 54f88a7e1..7ed7abb79 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -27,6 +27,7 @@ type Repository struct { IsPrivate bool HasIssuesEnabled bool + HasWikiEnabled bool ViewerPermission string DefaultBranchRef BranchRef @@ -94,6 +95,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { owner { login } hasIssuesEnabled description + hasWikiEnabled viewerPermission defaultBranchRef { name diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 73f4d7187..4e74dbc3c 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -87,6 +87,7 @@ func cloneRun(opts *CloneOptions) error { var repo ghrepo.Interface var protocol string + if repositoryIsURL { repoURL, err := git.ParseURL(opts.Repository) if err != nil { @@ -122,7 +123,12 @@ func cloneRun(opts *CloneOptions) error { return err } } - + // Check if the repo name has .wiki extension + wantsWiki := false + if strings.HasSuffix(repo.RepoName(), ".wiki") { + repo = ghrepo.NewWithHost(repo.RepoOwner(), strings.TrimSuffix(repo.RepoName(), ".wiki"), repo.RepoHost()) + wantsWiki = true + } // Load the repo from the API to get the username/repo name in its // canonical capitalization canonicalRepo, err := api.GitHubRepo(apiClient, repo) @@ -131,6 +137,14 @@ func cloneRun(opts *CloneOptions) error { } canonicalCloneURL := ghrepo.FormatRemoteURL(canonicalRepo, protocol) + // If repo HasWikiEnabled and wantsWiki is true then create a new clone URL + if wantsWiki { + if !canonicalRepo.HasWikiEnabled { + return fmt.Errorf("The '%s' repository does not have a wiki", ghrepo.FullName(canonicalRepo)) + } + canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git" + } + cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs) if err != nil { return err diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index e7aa57b08..88c653a89 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -56,6 +56,22 @@ func TestNewCmdClone(t *testing.T) { args: "OWNER/REPO --depth 1", wantErr: "unknown flag: --depth\nSeparate git clone flags with '--'.", }, + { + name: "wiki with HTTPS URL", + args: "https://github.com/OWNER/REPO.wiki.git", + wantOpts: CloneOptions{ + Repository: "https://github.com/OWNER/REPO.wiki.git", + GitArgs: []string{}, + }, + }, + { + name: "wiki with Full Name URL", + args: "OWNER/REPO.wiki", + wantOpts: CloneOptions{ + Repository: "OWNER/REPO.wiki", + GitArgs: []string{}, + }, + }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { @@ -282,3 +298,27 @@ func Test_RepoClone_withoutUsername(t *testing.T) { assert.Equal(t, 1, cs.Count) assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " ")) } + +func Test_RepoClone_wiki(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "name": "REPO", + "owner": { + "login": "OWNER" + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + _, err := runCloneCommand(httpClient, "Owner/repo.wiki") + if err != nil { + assert.Equal(t, "The 'OWNER/REPO' repository does not have a wiki", err.Error()) + } else { + t.Fatalf("error running command `repo clone`: %v", err) + } + reg.Verify(t) +} From 39a0a8c57c851a2763248550348043cf2af7e163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 15 Dec 2020 17:38:21 +0100 Subject: [PATCH 097/129] Improve clone wiki test --- pkg/cmd/repo/clone/clone.go | 11 ++++--- pkg/cmd/repo/clone/clone_test.go | 53 ++++++++------------------------ 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 4e74dbc3c..ee2a2ecdf 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -123,12 +123,13 @@ func cloneRun(opts *CloneOptions) error { return err } } - // Check if the repo name has .wiki extension - wantsWiki := false - if strings.HasSuffix(repo.RepoName(), ".wiki") { - repo = ghrepo.NewWithHost(repo.RepoOwner(), strings.TrimSuffix(repo.RepoName(), ".wiki"), repo.RepoHost()) - wantsWiki = true + + wantsWiki := strings.HasSuffix(repo.RepoName(), ".wiki") + if wantsWiki { + repoName := strings.TrimSuffix(repo.RepoName(), ".wiki") + repo = ghrepo.NewWithHost(repo.RepoOwner(), repoName, repo.RepoHost()) } + // Load the repo from the API to get the username/repo name in its // canonical capitalization canonicalRepo, err := api.GitHubRepo(apiClient, repo) diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 88c653a89..f6b612202 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -56,22 +56,6 @@ func TestNewCmdClone(t *testing.T) { args: "OWNER/REPO --depth 1", wantErr: "unknown flag: --depth\nSeparate git clone flags with '--'.", }, - { - name: "wiki with HTTPS URL", - args: "https://github.com/OWNER/REPO.wiki.git", - wantOpts: CloneOptions{ - Repository: "https://github.com/OWNER/REPO.wiki.git", - GitArgs: []string{}, - }, - }, - { - name: "wiki with Full Name URL", - args: "OWNER/REPO.wiki", - wantOpts: CloneOptions{ - Repository: "OWNER/REPO.wiki", - GitArgs: []string{}, - }, - }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { @@ -184,6 +168,16 @@ func Test_RepoClone(t *testing.T) { args: "Owner/Repo", want: "git clone https://github.com/OWNER/REPO.git", }, + { + name: "clone wiki", + args: "Owner/Repo.wiki", + want: "git clone https://github.com/OWNER/REPO.wiki.git", + }, + { + name: "wiki URL", + args: "https://github.com/owner/repo.wiki", + want: "git clone https://github.com/OWNER/REPO.wiki.git", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -195,7 +189,8 @@ func Test_RepoClone(t *testing.T) { "name": "REPO", "owner": { "login": "OWNER" - } + }, + "hasWikiEnabled": true } } } `)) @@ -298,27 +293,3 @@ func Test_RepoClone_withoutUsername(t *testing.T) { assert.Equal(t, 1, cs.Count) assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " ")) } - -func Test_RepoClone_wiki(t *testing.T) { - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "name": "REPO", - "owner": { - "login": "OWNER" - } - } } } - `)) - - httpClient := &http.Client{Transport: reg} - - _, err := runCloneCommand(httpClient, "Owner/repo.wiki") - if err != nil { - assert.Equal(t, "The 'OWNER/REPO' repository does not have a wiki", err.Error()) - } else { - t.Fatalf("error running command `repo clone`: %v", err) - } - reg.Verify(t) -} From 9d50221669483e086575d924a86d833a1b930bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Dec 2020 15:28:23 +0100 Subject: [PATCH 098/129] Improve `completion` docs for bash, zsh, fish --- pkg/cmd/completion/completion.go | 45 ++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index 3d3699b5d..24414a5fc 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { var shellType string cmd := &cobra.Command{ - Use: "completion", + Use: "completion -s ", Short: "Generate shell completion scripts", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Generate shell completion scripts for GitHub CLI commands. - The output of this command will be computer code and is meant to be saved to a - file or immediately evaluated by an interactive shell. - - For example, for bash you could add this to your '~/.bash_profile': - - eval "$(gh completion -s bash)" - - When installing GitHub CLI through a package manager, however, it's possible that + 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 - `), + + 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 + shell before testing whether completions are working. + + ### bash + + Add this to your %[1]s~/.bash_profile%[1]s: + + eval "$(gh completion -s bash)" + + ### zsh + + Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s: + + gh completion -s zsh > /usr/local/share/zsh/site-functions/_gh + + Ensure that the following is present in your %[1]s~/.zshrc%[1]s: + + autoload -U compinit + compinit -i + + Zsh version 5.7 or later is recommended. + + ### fish + + Generate a %[1]sgh.fish%[1]s completion script: + + gh completion -s fish > ~/.config/fish/completions/gh.fish + `, "`"), RunE: func(cmd *cobra.Command, args []string) error { if shellType == "" { if io.IsStdoutTTY() { @@ -54,6 +76,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { return fmt.Errorf("unsupported shell type %q", shellType) } }, + DisableFlagsInUseLine: true, } cmdutil.DisableAuthCheck(cmd) From 18b19e074da2c2f04af78c59c75d0a168662aed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 16 Dec 2020 17:07:41 +0100 Subject: [PATCH 099/129] Fix link in `version` output It now correctly links to the tagged release instead of to the latest release. --- pkg/cmd/version/version.go | 5 +++-- pkg/cmd/version/version_test.go | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 040a3132e..d473ab0b2 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -26,11 +26,12 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command func Format(version, buildDate string) string { version = strings.TrimPrefix(version, "v") + var dateStr string if buildDate != "" { - version = fmt.Sprintf("%s (%s)", version, buildDate) + dateStr = fmt.Sprintf(" (%s)", buildDate) } - return fmt.Sprintf("gh version %s\n%s\n", version, changelogURL(version)) + return fmt.Sprintf("gh version %s%s\n%s\n", version, dateStr, changelogURL(version)) } func changelogURL(version string) string { diff --git a/pkg/cmd/version/version_test.go b/pkg/cmd/version/version_test.go index 9a1d49db3..be1065dfd 100644 --- a/pkg/cmd/version/version_test.go +++ b/pkg/cmd/version/version_test.go @@ -4,6 +4,13 @@ import ( "testing" ) +func TestFormat(t *testing.T) { + expects := "gh version 1.4.0 (2020-12-15)\nhttps://github.com/cli/cli/releases/tag/v1.4.0\n" + if got := Format("1.4.0", "2020-12-15"); got != expects { + t.Errorf("Format() = %q, wants %q", got, expects) + } +} + func TestChangelogURL(t *testing.T) { tag := "0.3.2" url := "https://github.com/cli/cli/releases/tag/v0.3.2" From 7415d236bcc0088c338ac15f20f49d0c05b32fee Mon Sep 17 00:00:00 2001 From: marmorag Date: Wed, 16 Dec 2020 18:52:36 +0100 Subject: [PATCH 100/129] chore(deps): bump AlecAivazis/survey --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index bd4e69068..632ca264a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.2.3 + github.com/AlecAivazis/survey/v2 v2.2.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 diff --git a/go.sum b/go.sum index ba242e9dc..bc8a41e48 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AlecAivazis/survey/v2 v2.2.3 h1:utJR2X4Ibp2fBxdjalQUiMFf3zfQNjA15YE8+ftlKEs= -github.com/AlecAivazis/survey/v2 v2.2.3/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= +github.com/AlecAivazis/survey/v2 v2.2.7 h1:5NbxkF4RSKmpywYdcRgUmos1o+roJY8duCLZXbVjoig= +github.com/AlecAivazis/survey/v2 v2.2.7/go.mod h1:9DYvHgXtiXm6nCn+jXnOXLKbH+Yo9u8fAS/SduGdoPk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= From 9140e887085c962c0520d961cc4f23f83bf3b805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Dec 2020 16:12:57 +0100 Subject: [PATCH 101/129] Extract oauth package --- auth/oauth.go | 275 -------------------------------------- auth/oauth_test.go | 258 ----------------------------------- go.mod | 1 + go.sum | 4 + internal/authflow/flow.go | 51 ++++--- 5 files changed, 35 insertions(+), 554 deletions(-) delete mode 100644 auth/oauth.go delete mode 100644 auth/oauth_test.go diff --git a/auth/oauth.go b/auth/oauth.go deleted file mode 100644 index 2c8a78cd4..000000000 --- a/auth/oauth.go +++ /dev/null @@ -1,275 +0,0 @@ -package auth - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "io/ioutil" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/cli/cli/internal/ghinstance" -) - -func randomString(length int) (string, error) { - b := make([]byte, length/2) - _, err := rand.Read(b) - if err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// OAuthFlow represents the setup for authenticating with GitHub -type OAuthFlow struct { - Hostname string - ClientID string - ClientSecret string - Scopes []string - OpenInBrowser func(string, string) error - WriteSuccessHTML func(io.Writer) - VerboseStream io.Writer - HTTPClient *http.Client - TimeNow func() time.Time - TimeSleep func(time.Duration) -} - -func detectDeviceFlow(statusCode int, values url.Values) (bool, error) { - if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || - statusCode == http.StatusNotFound || statusCode == http.StatusUnprocessableEntity || - (statusCode == http.StatusOK && values == nil) || - (statusCode == http.StatusBadRequest && values != nil && values.Get("error") == "unauthorized_client") { - return true, nil - } else if statusCode != http.StatusOK { - if values != nil && values.Get("error_description") != "" { - return false, fmt.Errorf("HTTP %d: %s", statusCode, values.Get("error_description")) - } - return false, fmt.Errorf("error: HTTP %d", statusCode) - } - return false, nil -} - -// ObtainAccessToken guides the user through the browser OAuth flow on GitHub -// and returns the OAuth access token upon completion. -func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { - // first, check if OAuth Device Flow is supported - initURL := fmt.Sprintf("https://%s/login/device/code", oa.Hostname) - tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname) - - oa.logf("POST %s\n", initURL) - resp, err := oa.HTTPClient.PostForm(initURL, url.Values{ - "client_id": {oa.ClientID}, - "scope": {strings.Join(oa.Scopes, " ")}, - }) - if err != nil { - return - } - defer resp.Body.Close() - - var values url.Values - if strings.Contains(resp.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { - var bb []byte - bb, err = ioutil.ReadAll(resp.Body) - if err != nil { - return - } - values, err = url.ParseQuery(string(bb)) - if err != nil { - return - } - } - - if doFallback, err := detectDeviceFlow(resp.StatusCode, values); doFallback { - // OAuth Device Flow is not available; continue with OAuth browser flow with a - // local server endpoint as callback target - return oa.localServerFlow() - } else if err != nil { - return "", fmt.Errorf("%v (%s)", err, initURL) - } - - timeNow := oa.TimeNow - if timeNow == nil { - timeNow = time.Now - } - timeSleep := oa.TimeSleep - if timeSleep == nil { - timeSleep = time.Sleep - } - - intervalSeconds, err := strconv.Atoi(values.Get("interval")) - if err != nil { - return "", fmt.Errorf("could not parse interval=%q as integer: %w", values.Get("interval"), err) - } - checkInterval := time.Duration(intervalSeconds) * time.Second - - expiresIn, err := strconv.Atoi(values.Get("expires_in")) - if err != nil { - return "", fmt.Errorf("could not parse expires_in=%q as integer: %w", values.Get("expires_in"), err) - } - expiresAt := timeNow().Add(time.Duration(expiresIn) * time.Second) - - err = oa.OpenInBrowser(values.Get("verification_uri"), values.Get("user_code")) - if err != nil { - return - } - - for { - timeSleep(checkInterval) - accessToken, err = oa.deviceFlowPing(tokenURL, values.Get("device_code")) - if accessToken == "" && err == nil { - if timeNow().After(expiresAt) { - err = errors.New("authentication timed out") - } else { - continue - } - } - break - } - - return -} - -func (oa *OAuthFlow) deviceFlowPing(tokenURL, deviceCode string) (accessToken string, err error) { - oa.logf("POST %s\n", tokenURL) - resp, err := oa.HTTPClient.PostForm(tokenURL, url.Values{ - "client_id": {oa.ClientID}, - "device_code": {deviceCode}, - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - }) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("error: HTTP %d (%s)", resp.StatusCode, tokenURL) - } - - bb, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - values, err := url.ParseQuery(string(bb)) - if err != nil { - return "", err - } - - if accessToken := values.Get("access_token"); accessToken != "" { - return accessToken, nil - } - - errorType := values.Get("error") - if errorType == "authorization_pending" { - return "", nil - } - - if errorDescription := values.Get("error_description"); errorDescription != "" { - return "", errors.New(errorDescription) - } - return "", errors.New("OAuth device flow error") -} - -func (oa *OAuthFlow) localServerFlow() (accessToken string, err error) { - state, _ := randomString(20) - - code := "" - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - return - } - port := listener.Addr().(*net.TCPAddr).Port - - scopes := "repo" - if oa.Scopes != nil { - scopes = strings.Join(oa.Scopes, " ") - } - - localhost := "127.0.0.1" - callbackPath := "/callback" - if ghinstance.IsEnterprise(oa.Hostname) { - // the OAuth app on Enterprise hosts is still registered with a legacy callback URL - // see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650 - localhost = "localhost" - callbackPath = "/" - } - - q := url.Values{} - q.Set("client_id", oa.ClientID) - q.Set("redirect_uri", fmt.Sprintf("http://%s:%d%s", localhost, port, callbackPath)) - q.Set("scope", scopes) - q.Set("state", state) - - startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode()) - oa.logf("open %s\n", startURL) - err = oa.OpenInBrowser(startURL, "") - if err != nil { - return - } - - _ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - oa.logf("server handler: %s\n", r.URL.Path) - if r.URL.Path != callbackPath { - w.WriteHeader(404) - return - } - defer listener.Close() - rq := r.URL.Query() - if state != rq.Get("state") { - fmt.Fprintf(w, "Error: state mismatch") - return - } - code = rq.Get("code") - oa.logf("server received code %q\n", code) - w.Header().Add("content-type", "text/html") - if oa.WriteSuccessHTML != nil { - oa.WriteSuccessHTML(w) - } else { - fmt.Fprintf(w, "

You have successfully authenticated. You may now close this page.

") - } - })) - - tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname) - oa.logf("POST %s\n", tokenURL) - resp, err := oa.HTTPClient.PostForm(tokenURL, - url.Values{ - "client_id": {oa.ClientID}, - "client_secret": {oa.ClientSecret}, - "code": {code}, - "state": {state}, - }) - if err != nil { - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - err = fmt.Errorf("HTTP %d error while obtaining OAuth access token", resp.StatusCode) - return - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return - } - tokenValues, err := url.ParseQuery(string(body)) - if err != nil { - return - } - accessToken = tokenValues.Get("access_token") - if accessToken == "" { - err = errors.New("the access token could not be read from HTTP response") - } - return -} - -func (oa *OAuthFlow) logf(format string, args ...interface{}) { - if oa.VerboseStream == nil { - return - } - fmt.Fprintf(oa.VerboseStream, format, args...) -} diff --git a/auth/oauth_test.go b/auth/oauth_test.go deleted file mode 100644 index a9070a1b1..000000000 --- a/auth/oauth_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package auth - -import ( - "bytes" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "testing" - "time" -) - -type roundTripper func(*http.Request) (*http.Response, error) - -func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return rt(req) -} - -func TestObtainAccessToken_deviceFlow(t *testing.T) { - requestCount := 0 - rt := func(req *http.Request) (*http.Response, error) { - route := fmt.Sprintf("%s %s", req.Method, req.URL) - switch route { - case "POST https://github.com/login/device/code": - if err := req.ParseForm(); err != nil { - return nil, err - } - if req.PostForm.Get("client_id") != "CLIENT-ID" { - t.Errorf("expected POST /login/device/code to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id")) - } - if req.PostForm.Get("scope") != "repo gist" { - t.Errorf("expected POST /login/device/code to supply scope=%q, got %q", "repo gist", req.PostForm.Get("scope")) - } - - responseData := url.Values{} - responseData.Set("device_code", "DEVICE-CODE") - responseData.Set("user_code", "1234-ABCD") - responseData.Set("verification_uri", "https://github.com/login/device") - responseData.Set("interval", "5") - responseData.Set("expires_in", "899") - - return &http.Response{ - StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())), - Header: http.Header{ - "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, - }, - }, nil - case "POST https://github.com/login/oauth/access_token": - if err := req.ParseForm(); err != nil { - return nil, err - } - if req.PostForm.Get("client_id") != "CLIENT-ID" { - t.Errorf("expected POST /login/oauth/access_token to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id")) - } - if req.PostForm.Get("device_code") != "DEVICE-CODE" { - t.Errorf("expected POST /login/oauth/access_token to supply device_code=%q, got %q", "DEVICE-CODE", req.PostForm.Get("scope")) - } - if req.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { - t.Errorf("expected POST /login/oauth/access_token to supply grant_type=%q, got %q", "urn:ietf:params:oauth:grant-type:device_code", req.PostForm.Get("grant_type")) - } - - responseData := url.Values{} - requestCount++ - if requestCount == 1 { - responseData.Set("error", "authorization_pending") - } else { - responseData.Set("access_token", "OTOKEN") - } - - return &http.Response{ - StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())), - }, nil - default: - return nil, fmt.Errorf("unstubbed HTTP request: %v", route) - } - } - httpClient := &http.Client{ - Transport: roundTripper(rt), - } - - slept := time.Duration(0) - var browseURL string - var browseCode string - - oa := &OAuthFlow{ - Hostname: "github.com", - ClientID: "CLIENT-ID", - ClientSecret: "CLIENT-SEKRIT", - Scopes: []string{"repo", "gist"}, - OpenInBrowser: func(url, code string) error { - browseURL = url - browseCode = code - return nil - }, - HTTPClient: httpClient, - TimeNow: time.Now, - TimeSleep: func(d time.Duration) { - slept += d - }, - } - - token, err := oa.ObtainAccessToken() - if err != nil { - t.Fatalf("ObtainAccessToken error: %v", err) - } - - if token != "OTOKEN" { - t.Errorf("expected token %q, got %q", "OTOKEN", token) - } - if requestCount != 2 { - t.Errorf("expected 2 HTTP pings for token, got %d", requestCount) - } - if slept.String() != "10s" { - t.Errorf("expected total sleep duration of %s, got %s", "10s", slept.String()) - } - if browseURL != "https://github.com/login/device" { - t.Errorf("expected to open browser at %s, got %s", "https://github.com/login/device", browseURL) - } - if browseCode != "1234-ABCD" { - t.Errorf("expected to provide user with one-time code %q, got %q", "1234-ABCD", browseCode) - } -} - -func Test_detectDeviceFlow(t *testing.T) { - type args struct { - statusCode int - values url.Values - } - tests := []struct { - name string - args args - doFallback bool - wantErr string - }{ - { - name: "success", - args: args{ - statusCode: 200, - values: url.Values{}, - }, - doFallback: false, - wantErr: "", - }, - { - name: "wrong response type", - args: args{ - statusCode: 200, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "401 unauthorized", - args: args{ - statusCode: 401, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "403 forbidden", - args: args{ - statusCode: 403, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "404 not found", - args: args{ - statusCode: 404, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "422 unprocessable", - args: args{ - statusCode: 422, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "402 payment required", - args: args{ - statusCode: 402, - values: nil, - }, - doFallback: false, - wantErr: "error: HTTP 402", - }, - { - name: "400 bad request", - args: args{ - statusCode: 400, - values: nil, - }, - doFallback: false, - wantErr: "error: HTTP 400", - }, - { - name: "400 with values", - args: args{ - statusCode: 400, - values: url.Values{ - "error": []string{"blah"}, - }, - }, - doFallback: false, - wantErr: "error: HTTP 400", - }, - { - name: "400 with unauthorized_client", - args: args{ - statusCode: 400, - values: url.Values{ - "error": []string{"unauthorized_client"}, - }, - }, - doFallback: true, - wantErr: "", - }, - { - name: "400 with error_description", - args: args{ - statusCode: 400, - values: url.Values{ - "error_description": []string{"HI"}, - }, - }, - doFallback: false, - wantErr: "HTTP 400: HI", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := detectDeviceFlow(tt.args.statusCode, tt.args.values) - if (err != nil) != (tt.wantErr != "") { - t.Errorf("detectDeviceFlow() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr != "" && err.Error() != tt.wantErr { - t.Errorf("error = %q, wantErr = %q", err, tt.wantErr) - } - if got != tt.doFallback { - t.Errorf("detectDeviceFlow() = %v, want %v", got, tt.doFallback) - } - }) - } -} diff --git a/go.mod b/go.mod index bd4e69068..0faac7954 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 + github.com/cli/oauth v0.8.0 github.com/cli/safeexec v1.0.0 github.com/cpuguy83/go-md2man/v2 v2.0.0 github.com/enescakir/emoji v1.0.0 diff --git a/go.sum b/go.sum index ba242e9dc..3f450d73d 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,10 @@ github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 h1:YMyvXRstOQc7n6eneHfudVMbARSCmZ7EZGjtTkkeB3A= github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684/go.mod h1:UA27Kwj3QHialP74iU6C+Gpc8Y7IOAKupeKMLLBURWM= +github.com/cli/browser v1.0.0 h1:RIleZgXrhdiCVgFBSjtWwkLPUCWyhhhN5k5HGSBt1js= +github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= +github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA= +github.com/cli/oauth v0.8.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e h1:aq/1jlmtZoS6nlSp3yLOTZQ50G+dzHdeRNENgE/iBew= diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 65c17fd0b..efe8d3560 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -5,14 +5,13 @@ import ( "fmt" "io" "net/http" - "os" - "strings" "github.com/cli/cli/api" - "github.com/cli/cli/auth" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/browser" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/oauth" ) var ( @@ -58,28 +57,33 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition w := IO.ErrOut cs := IO.ColorScheme() - var verboseStream io.Writer - if strings.Contains(os.Getenv("DEBUG"), "oauth") { - verboseStream = w - } + httpClient := http.DefaultClient + // TODO: print HTTP logs in debug mode + // if strings.Contains(os.Getenv("DEBUG"), "oauth") { + // httpClient = ... + // } minimumScopes := []string{"repo", "read:org", "gist", "workflow"} scopes := append(minimumScopes, additionalScopes...) - flow := &auth.OAuthFlow{ + callbackURI := "http://127.0.0.1/callback" + if ghinstance.IsEnterprise(oauthHost) { + // the OAuth app on Enterprise hosts is still registered with a legacy callback URL + // see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650 + callbackURI = "http://localhost/" + } + + flow := &oauth.Flow{ Hostname: oauthHost, ClientID: oauthClientID, ClientSecret: oauthClientSecret, + CallbackURI: callbackURI, Scopes: scopes, - WriteSuccessHTML: func(w io.Writer) { - fmt.Fprintln(w, oauthSuccessPage) + DisplayCode: func(code, verificationURL string) error { + fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code)) + return nil }, - VerboseStream: verboseStream, - HTTPClient: http.DefaultClient, - OpenInBrowser: func(url, code string) error { - if code != "" { - fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code)) - } + BrowseURL: func(url string) error { fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost) _ = waitForEnter(IO.In) @@ -87,29 +91,34 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition if err != nil { return err } - err = browseCmd.Run() - if err != nil { + if err := browseCmd.Run(); err != nil { fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), url) fmt.Fprintf(w, " %s\n", err) fmt.Fprint(w, " Please try entering the URL in your browser manually\n") } return nil }, + WriteSuccessHTML: func(w io.Writer) { + fmt.Fprintln(w, oauthSuccessPage) + }, + HTTPClient: httpClient, + Stdin: IO.In, + Stdout: w, } fmt.Fprintln(w, notice) - token, err := flow.ObtainAccessToken() + token, err := flow.DetectFlow() if err != nil { return "", "", err } - userLogin, err := getViewer(oauthHost, token) + userLogin, err := getViewer(oauthHost, token.Token) if err != nil { return "", "", err } - return token, userLogin, nil + return token.Token, userLogin, nil } func getViewer(hostname, token string) (string, error) { From 8ff4cc40e642cc90715fabc5bdfc04bc698e74d0 Mon Sep 17 00:00:00 2001 From: Camelid Date: Sun, 27 Dec 2020 11:37:59 -0800 Subject: [PATCH 102/129] view: Add missing newline Add a newline at the end of the 'View this {issue, pull request} on GitHub' message. `gh repo view` already had a newline at the end, so this only changes `issue view` and `pr view`. --- pkg/cmd/issue/view/view.go | 2 +- pkg/cmd/pr/view/view.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index e8ff2b274..811f88088 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -204,7 +204,7 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { } // Footer - fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s"), issue.URL) + fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) return nil } diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 8384d7af3..ca3f0580d 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -223,7 +223,7 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { } // Footer - fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s"), pr.URL) + fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) return nil } From 21a57d9253b3bc1e9e2ec91a33d3853c768e9d59 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Mon, 4 Jan 2021 07:35:41 -0300 Subject: [PATCH 103/129] Remove unused methods --- pkg/httpmock/legacy.go | 51 ------------------------------------------ 1 file changed, 51 deletions(-) diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go index 9b5d5afef..c6f9e56db 100644 --- a/pkg/httpmock/legacy.go +++ b/pkg/httpmock/legacy.go @@ -5,8 +5,6 @@ import ( "io" "net/http" "os" - "path" - "strings" ) // TODO: clean up methods in this file when there are no more callers @@ -17,22 +15,6 @@ func (r *Registry) StubResponse(status int, body io.Reader) { }) } -func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() { - fixturePath := path.Join("../test/fixtures/", fixtureFileName) - fixtureFile, err := os.Open(fixturePath) - r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { - if err != nil { - return nil, err - } - return httpResponse(200, req, fixtureFile), nil - }) - return func() { - if err == nil { - fixtureFile.Close() - } - } -} - func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() { fixtureFile, err := os.Open(fixturePath) r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { @@ -72,14 +54,6 @@ func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission))) } -func (r *Registry) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) { - r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, defaultBranch, "WRITE"))) -} - -func (r *Registry) StubForkedRepoResponse(ownRepo, parentRepo string) { - r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubForkResponse(ownRepo, parentRepo))) -} - func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string { return fmt.Sprintf(` { "data": { "repo_000": { @@ -93,28 +67,3 @@ func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) stri } } } `, repo, owner, defaultBranch, permission) } - -func RepoNetworkStubForkResponse(forkFullName, parentFullName string) string { - forkRepo := strings.SplitN(forkFullName, "/", 2) - parentRepo := strings.SplitN(parentFullName, "/", 2) - return fmt.Sprintf(` - { "data": { "repo_000": { - "id": "REPOID2", - "name": "%s", - "owner": {"login": "%s"}, - "defaultBranchRef": { - "name": "master" - }, - "viewerPermission": "ADMIN", - "parent": { - "id": "REPOID1", - "name": "%s", - "owner": {"login": "%s"}, - "defaultBranchRef": { - "name": "master" - }, - "viewerPermission": "READ" - } - } } } - `, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0]) -} From 536a173364758194ab3a30daddc5e74b11eb2dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 5 Jan 2021 16:24:47 +0100 Subject: [PATCH 104/129] Enable debugging HTTP traffic during `auth login/refresh` --- internal/authflow/flow.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index efe8d3560..fac9d31a6 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "os" + "strings" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" @@ -58,10 +60,10 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition cs := IO.ColorScheme() httpClient := http.DefaultClient - // TODO: print HTTP logs in debug mode - // if strings.Contains(os.Getenv("DEBUG"), "oauth") { - // httpClient = ... - // } + if envDebug := os.Getenv("DEBUG"); envDebug != "" { + logTraffic := strings.Contains(envDebug, "api") || strings.Contains(envDebug, "oauth") + httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport) + } minimumScopes := []string{"repo", "read:org", "gist", "workflow"} scopes := append(minimumScopes, additionalScopes...) From 8ef2bb4d14508d80080b6c9c98cfa930a8b1db0b Mon Sep 17 00:00:00 2001 From: Yuki Osaki Date: Tue, 29 Sep 2020 00:10:11 +0900 Subject: [PATCH 105/129] Comment on issues from editor --- api/queries_issue.go | 19 +++ pkg/cmd/issue/comment/comment.go | 202 ++++++++++++++++++++++++++ pkg/cmd/issue/comment/comment_test.go | 117 +++++++++++++++ pkg/cmd/issue/issue.go | 2 + 4 files changed, 340 insertions(+) create mode 100644 pkg/cmd/issue/comment/comment.go create mode 100644 pkg/cmd/issue/comment/comment_test.go diff --git a/api/queries_issue.go b/api/queries_issue.go index 17f32eabd..e561bf19c 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -123,6 +123,25 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } +func CommentCreate(client *Client, repoHost string, params map[string]interface{}) error { + query := ` + mutation CommentCreate($input: AddCommentInput!) { + addComment(input: $input) { clientMutationId } + }` + + variables := map[string]interface{}{ + "input": params, + } + + err := client.GraphQL(repoHost, query, variables, nil) + + if err != nil { + return err + } + + return nil +} + func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { type response struct { Repository struct { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go new file mode 100644 index 000000000..9857802b8 --- /dev/null +++ b/pkg/cmd/issue/comment/comment.go @@ -0,0 +1,202 @@ +package comment + +import ( + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/spf13/cobra" +) + +type CommentOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Body string + SelectorArg string + Interactive bool + Action Action +} + +func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { + opts := &CommentOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "comment { | }", + Short: "Create comments for the issue", + Example: heredoc.Doc(` + $ gh issue comment --body "I found a bug. Nothing works" + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + bodyProvided := cmd.Flags().Changed("body") + + opts.Interactive = !(bodyProvided) + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return commentRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body.") + + return cmd +} + +type Action int + +const ( + SubmitAction Action = iota + CancelAction +) + +func titleBodySurvey(editorCommand string, issueState *CommentOptions, apiClient *api.Client, repo ghrepo.Interface, providedBody string) error { + bodyQuestion := &survey.Question{ + Name: "body", + Prompt: &surveyext.GhEditor{ + BlankAllowed: false, + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Body", + FileName: "*.md", + Default: issueState.Body, + }, + }, + } + + var qs []*survey.Question + + if providedBody == "" { + qs = append(qs, bodyQuestion) + } + + err := prompt.SurveyAsk(qs, issueState) + if err != nil { + panic(fmt.Sprintf("could not prompt: %w", err)) + } + + confirmA, err := confirmSubmission() + + if err != nil { + panic(fmt.Sprintf("unable to confirm: %w", err)) + } + + issueState.Action = confirmA + return nil +} + +func confirmSubmission() (Action, error) { + const ( + submitLabel = "Submit" + cancelLabel = "Cancel" + ) + + options := []string{submitLabel, cancelLabel} + + confirmAnswers := struct { + Confirmation int + }{} + confirmQs := []*survey.Question{ + { + Name: "confirmation", + Prompt: &survey.Select{ + Message: "What's next?", + Options: options, + }, + }, + } + + err := prompt.SurveyAsk(confirmQs, &confirmAnswers) + if err != nil { + return -1, fmt.Errorf("could not prompt: %w", err) + } + + switch options[confirmAnswers.Confirmation] { + case submitLabel: + return SubmitAction, nil + case cancelLabel: + return CancelAction, nil + default: + return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation) + } +} + +func commentRun(opts *CommentOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + + if err != nil { + return err + } + + isTerminal := opts.IO.IsStdoutTTY() + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "\nMake a comment for %s in %s\n\n", issue.Title, ghrepo.FullName(baseRepo)) + } + + action := SubmitAction + body := opts.Body + + if opts.Interactive { + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = titleBodySurvey(editorCommand, opts, apiClient, baseRepo, body) + + if err != nil { + return err + } + + action = opts.Action + } + + if action == CancelAction { + return nil + } else if action == SubmitAction { + params := map[string]interface{}{ + "subjectId": issue.ID, + "body": opts.Body, + } + + err = api.CommentCreate(apiClient, baseRepo.RepoHost(), params) + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, issue.URL) + + return nil + } + return fmt.Errorf("unexpected action state: %v", action) +} diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go new file mode 100644 index 000000000..fe101a797 --- /dev/null +++ b/pkg/cmd/issue/comment/comment_test.go @@ -0,0 +1,117 @@ +package comment + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "reflect" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + 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 := NewCmdComment(factory, func(opts *CommentOptions) error { + return commentRun(opts) + }) + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestCommentCreate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 96, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"mutationId": "THE-ID"}`)) + + output, err := runCommand(http, true, `13 -b "cash rules everything around me"`) + if err != nil { + t.Errorf("error running command `issue comment`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") + + r := regexp.MustCompile(`Make a comment for The title of the issue`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssue_Disabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "13") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 6f2cea6d9..12463b480 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -3,6 +3,7 @@ package issue import ( "github.com/MakeNowJust/heredoc" cmdClose "github.com/cli/cli/pkg/cmd/issue/close" + cmdComment "github.com/cli/cli/pkg/cmd/issue/comment" cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" cmdList "github.com/cli/cli/pkg/cmd/issue/list" cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" @@ -40,6 +41,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdComment.NewCmdComment(f, nil)) return cmd } From 338bf1d112bd4ee7d87991b4c0fb49ba671c5e1c Mon Sep 17 00:00:00 2001 From: Yuki Osaki Date: Tue, 29 Sep 2020 00:32:03 +0900 Subject: [PATCH 106/129] fix linter issue --- pkg/cmd/issue/comment/comment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 9857802b8..ec4459358 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -95,13 +95,13 @@ func titleBodySurvey(editorCommand string, issueState *CommentOptions, apiClient err := prompt.SurveyAsk(qs, issueState) if err != nil { - panic(fmt.Sprintf("could not prompt: %w", err)) + panic(fmt.Sprintf("could not prompt: %v", err)) } confirmA, err := confirmSubmission() if err != nil { - panic(fmt.Sprintf("unable to confirm: %w", err)) + panic(fmt.Sprintf("unable to confirm: %v", err)) } issueState.Action = confirmA From f862123071c7f485aec4396282f74c1b762b828a Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 30 Nov 2020 16:00:30 -0500 Subject: [PATCH 107/129] Modify issue commenting to adhere to designs and add tests --- api/queries_issue.go | 25 +- pkg/cmd/issue/comment/comment.go | 255 +++++++++--------- pkg/cmd/issue/comment/comment_test.go | 369 ++++++++++++++++++++------ 3 files changed, 437 insertions(+), 212 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index e561bf19c..8826f2e7c 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -123,23 +123,38 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } -func CommentCreate(client *Client, repoHost string, params map[string]interface{}) error { +func CommentCreate(client *Client, repoHost string, params map[string]string) (string, error) { query := ` mutation CommentCreate($input: AddCommentInput!) { - addComment(input: $input) { clientMutationId } + addComment(input: $input) { + commentEdge { + node { + url + } + } + } }` variables := map[string]interface{}{ "input": params, } - err := client.GraphQL(repoHost, query, variables, nil) + result := struct { + AddComment struct { + CommentEdge struct { + Node struct { + URL string + } + } + } + }{} + err := client.GraphQL(repoHost, query, variables, &result) if err != nil { - return err + return "", err } - return nil + return result.AddComment.CommentEdge.Node.URL, nil } func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index ec4459358..09e77a520 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -1,6 +1,7 @@ package comment import ( + "errors" "fmt" "net/http" @@ -14,44 +15,69 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" + "github.com/cli/cli/utils" "github.com/spf13/cobra" ) type CommentOptions struct { - HttpClient func() (*http.Client, error) - Config func() (config.Config, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Body string + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Edit func(string) (string, error) + SelectorArg string Interactive bool - Action Action + InputType int + Body string } +const ( + inline = iota + editor + web +) + func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { opts := &CommentOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, + Edit: func(editorCommand string) (string, error) { + return surveyext.Edit(editorCommand, "*.md", "", f.IOStreams.In, f.IOStreams.Out, f.IOStreams.ErrOut, nil) + }, } + var webMode bool + var editorMode bool + cmd := &cobra.Command{ Use: "comment { | }", - Short: "Create comments for the issue", + Short: "Create a new issue comment", Example: heredoc.Doc(` - $ gh issue comment --body "I found a bug. Nothing works" + $ gh issue comment 22 --body "I found a bug. Nothing works" `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo + opts.SelectorArg = args[0] + opts.Interactive = !(opts.Body != "" || webMode || editorMode) - bodyProvided := cmd.Flags().Changed("body") + if !opts.IO.CanPrompt() && opts.Interactive { + return &cmdutil.FlagError{Err: errors.New("--body, --editor, or --web required when not running interactively")} + } - opts.Interactive = !(bodyProvided) - - if len(args) > 0 { - opts.SelectorArg = args[0] + if !opts.Interactive { + if opts.Body != "" && !webMode && !editorMode { + opts.InputType = inline + } else if opts.Body == "" && webMode && !editorMode { + opts.InputType = web + } else if opts.Body == "" && !webMode && editorMode { + opts.InputType = editor + } else { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")} + } } if runF != nil { @@ -60,90 +86,13 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra. return commentRun(opts) }, } - - cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body.") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + cmd.Flags().BoolVarP(&editorMode, "editor", "e", false, "Add body using editor") + cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Add body in browser") return cmd } -type Action int - -const ( - SubmitAction Action = iota - CancelAction -) - -func titleBodySurvey(editorCommand string, issueState *CommentOptions, apiClient *api.Client, repo ghrepo.Interface, providedBody string) error { - bodyQuestion := &survey.Question{ - Name: "body", - Prompt: &surveyext.GhEditor{ - BlankAllowed: false, - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Body", - FileName: "*.md", - Default: issueState.Body, - }, - }, - } - - var qs []*survey.Question - - if providedBody == "" { - qs = append(qs, bodyQuestion) - } - - err := prompt.SurveyAsk(qs, issueState) - if err != nil { - panic(fmt.Sprintf("could not prompt: %v", err)) - } - - confirmA, err := confirmSubmission() - - if err != nil { - panic(fmt.Sprintf("unable to confirm: %v", err)) - } - - issueState.Action = confirmA - return nil -} - -func confirmSubmission() (Action, error) { - const ( - submitLabel = "Submit" - cancelLabel = "Cancel" - ) - - options := []string{submitLabel, cancelLabel} - - confirmAnswers := struct { - Confirmation int - }{} - confirmQs := []*survey.Question{ - { - Name: "confirmation", - Prompt: &survey.Select{ - Message: "What's next?", - Options: options, - }, - }, - } - - err := prompt.SurveyAsk(confirmQs, &confirmAnswers) - if err != nil { - return -1, fmt.Errorf("could not prompt: %w", err) - } - - switch options[confirmAnswers.Confirmation] { - case submitLabel: - return SubmitAction, nil - case cancelLabel: - return CancelAction, nil - default: - return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation) - } -} - func commentRun(opts *CommentOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -152,51 +101,115 @@ func commentRun(opts *CommentOptions) error { apiClient := api.NewClientFromHTTP(httpClient) issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) - if err != nil { return err } - isTerminal := opts.IO.IsStdoutTTY() - - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "\nMake a comment for %s in %s\n\n", issue.Title, ghrepo.FullName(baseRepo)) + if opts.Interactive { + inputType, err := inputTypeSurvey() + if err != nil { + return err + } + opts.InputType = inputType } - action := SubmitAction - body := opts.Body - - if opts.Interactive { + switch opts.InputType { + case web: + openURL := issue.URL + "#issuecomment-new" + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + case inline: + if opts.Body != "" { + break + } + body, err := inlineSurvey() + if err != nil { + return err + } + opts.Body = body + case editor: editorCommand, err := cmdutil.DetermineEditor(opts.Config) if err != nil { return err } - - err = titleBodySurvey(editorCommand, opts, apiClient, baseRepo, body) - + var body string + if opts.Interactive { + body, err = editorSurvey(editorCommand) + } else { + body, err = opts.Edit(editorCommand) + } if err != nil { return err } - - action = opts.Action + opts.Body = body } - if action == CancelAction { - return nil - } else if action == SubmitAction { - params := map[string]interface{}{ - "subjectId": issue.ID, - "body": opts.Body, - } - - err = api.CommentCreate(apiClient, baseRepo.RepoHost(), params) + if opts.Interactive { + cont, err := confirmSubmitSurvey() if err != nil { return err } - - fmt.Fprintln(opts.IO.Out, issue.URL) - - return nil + if !cont { + return fmt.Errorf("Discarding...") + } } - return fmt.Errorf("unexpected action state: %v", action) + + params := map[string]string{ + "subjectId": issue.ID, + "body": opts.Body, + } + + url, err := api.CommentCreate(apiClient, baseRepo.RepoHost(), params) + if err != nil { + return err + } + + fmt.Fprint(opts.IO.Out, url) + + return nil +} + +func inputTypeSurvey() (int, error) { + var inputType int + inputTypeQuestion := &survey.Select{ + Message: "Where do you want to draft your comment?", + Options: []string{"In line", "Editor", "Web"}, + } + err := prompt.SurveyAskOne(inputTypeQuestion, &inputType) + return inputType, err +} + +func inlineSurvey() (string, error) { + var body string + bodyQuestion := &survey.Input{ + Message: "Body", + } + err := prompt.SurveyAskOne(bodyQuestion, &body) + return body, err +} + +func editorSurvey(editorCommand string) (string, error) { + var body string + bodyQuestion := &surveyext.GhEditor{ + BlankAllowed: false, + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Body", + FileName: "*.md", + }, + } + err := prompt.SurveyAskOne(bodyQuestion, &body) + return body, err +} + +func confirmSubmitSurvey() (bool, error) { + var confirm bool + submit := &survey.Confirm{ + Message: "Submit?", + Default: true, + } + err := prompt.SurveyAskOne(submit, &confirm) + return confirm, err } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index fe101a797..e601ffbb8 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -2,116 +2,313 @@ package comment import ( "bytes" - "encoding/json" - "io/ioutil" "net/http" - "reflect" - "regexp" + "os/exec" "testing" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) +func TestNewCmdComment(t *testing.T) { + tests := []struct { + name string + input string + output CommentOptions + wantsErr bool + }{ + { + name: "no arguments", + input: "", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "issue number", + input: "1", + output: CommentOptions{ + SelectorArg: "1", + Interactive: true, + InputType: 0, + Body: "", + }, + wantsErr: false, + }, + { + name: "issue url", + input: "https://github.com/OWNER/REPO/issues/12", + output: CommentOptions{ + SelectorArg: "https://github.com/OWNER/REPO/issues/12", + Interactive: true, + InputType: 0, + Body: "", + }, + wantsErr: false, + }, + { + name: "body flag", + input: "1 --body test", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: inline, + Body: "test", + }, + wantsErr: false, + }, + { + name: "editor flag", + input: "1 --editor", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: editor, + Body: "", + }, + wantsErr: false, + }, + { + name: "web flag", + input: "1 --web", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: web, + Body: "", + }, + wantsErr: false, + }, + { + name: "editor and web flags", + input: "1 --editor --web", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "editor and body flags", + input: "1 --editor --body test", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "web and body flags", + input: "1 --web --body test", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "editor, web, and body flags", + input: "1 --editor --web --body test", + output: CommentOptions{}, + wantsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var gotOpts *CommentOptions + cmd := NewCmdComment(f, func(opts *CommentOptions) error { + gotOpts = opts + return nil + }) + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) + assert.Equal(t, tt.output.InputType, gotOpts.InputType) + assert.Equal(t, tt.output.Body, gotOpts.Body) + }) } } -func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { - io, _, stdout, stderr := iostreams.Test() - io.SetStdoutTTY(isTTY) - io.SetStdinTTY(isTTY) - io.SetStderrTTY(isTTY) - factory := &cmdutil.Factory{ - IOStreams: io, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: rt}, nil +func Test_commentRun(t *testing.T) { + tests := []struct { + name string + input *CommentOptions + testInputType int + stdout string + stderr string + wantsErr bool + errMsg string + }{ + { + name: "interactive web", + testInputType: web, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: true, + InputType: 0, + Body: "", + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", }, - Config: func() (config.Config, error) { + { + name: "interactive editor", + testInputType: editor, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: true, + InputType: 0, + Body: "", + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + }, + { + name: "interactive inline", + testInputType: inline, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: true, + InputType: 0, + Body: "", + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + }, + { + name: "non-interactive web", + testInputType: web, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: web, + Body: "", + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", + }, + { + name: "non-interactive editor", + testInputType: editor, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: editor, + Body: "", + Edit: func(string) (string, error) { return "comment body", nil }, + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + }, + { + name: "non-interactive inline", + testInputType: inline, + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: inline, + Body: "comment body", + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + }, + } + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + client := &httpmock.Registry{} + defer client.Verify(t) + mockIssueFromNumber(client) + if tt.testInputType != web { + mockCommentCreate(t, client) + } + + tt.input.IO = io + tt.input.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: client}, nil + } + tt.input.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { + } + tt.input.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil - }, - } + } - cmd := NewCmdComment(factory, func(opts *CommentOptions) error { - return commentRun(opts) - }) - argv, err := shlex.Split(cli) - if err != nil { - return nil, err - } - cmd.SetArgs(argv) - - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(ioutil.Discard) - cmd.SetErr(ioutil.Discard) - - _, err = cmd.ExecuteC() - return &test.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - }, err -} - -func TestCommentCreate(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 96, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"mutationId": "THE-ID"}`)) - - output, err := runCommand(http, true, `13 -b "cash rules everything around me"`) - if err != nil { - t.Errorf("error running command `issue comment`: %v", err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Body string + if tt.input.Interactive { + as, teardown := prompt.InitAskStubber() + defer teardown() + // Input type select + as.StubOne(tt.testInputType) + if tt.testInputType != web { + // Comment body + as.StubOne("comment body") + // Confirm submit + as.StubOne(true) } } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") + if tt.testInputType == web { + // Stub browser open + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + return &test.OutputStub{} + }) + defer restoreCmd() + } - r := regexp.MustCompile(`Make a comment for The title of the issue`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + t.Run(tt.name, func(t *testing.T) { + err := commentRun(tt.input) + if tt.wantsErr { + assert.EqualError(t, err, tt.errMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.stdout, stdout.String()) + assert.Equal(t, tt.stderr, stderr.String()) + }) } } -func TestIssue_Disabled(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := runCommand(http, true, "13") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } +func mockIssueFromNumber(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } }`), + ) +} + +func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "addComment": { "commentEdge": { "node": { + "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-456" + } } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "comment body", inputs["body"]) + }), + ) } From 1fc8b66b261bdd0b9080abb171b7f0c0c8ed668b Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 4 Jan 2021 15:58:57 -0800 Subject: [PATCH 108/129] Address PR comments --- api/queries_issue.go | 34 ++++------ pkg/cmd/issue/comment/comment.go | 92 ++++++++++----------------- pkg/cmd/issue/comment/comment_test.go | 22 ++----- pkg/iostreams/color.go | 26 +++++--- 4 files changed, 70 insertions(+), 104 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 8826f2e7c..4c4c85b72 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/internal/ghrepo" "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" ) type IssuesPayload struct { @@ -124,37 +125,30 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} } func CommentCreate(client *Client, repoHost string, params map[string]string) (string, error) { - query := ` - mutation CommentCreate($input: AddCommentInput!) { - addComment(input: $input) { - commentEdge { - node { - url - } - } - } - }` - - variables := map[string]interface{}{ - "input": params, - } - - result := struct { + var mutation struct { AddComment struct { CommentEdge struct { Node struct { URL string } } - } - }{} + } `graphql:"addComment(input: $input)"` + } - err := client.GraphQL(repoHost, query, variables, &result) + variables := map[string]interface{}{ + "input": githubv4.AddCommentInput{ + Body: githubv4.String(params["body"]), + SubjectID: graphql.ID(params["subjectId"]), + }, + } + + gql := graphQLClient(client.http, repoHost) + err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables) if err != nil { return "", err } - return result.AddComment.CommentEdge.Node.URL, nil + return mutation.AddComment.CommentEdge.Node.URL, nil } func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 09e77a520..5e73531fc 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -33,9 +33,9 @@ type CommentOptions struct { } const ( - inline = iota - editor + editor = iota web + inline ) func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { @@ -55,29 +55,39 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra. Use: "comment { | }", Short: "Create a new issue comment", Example: heredoc.Doc(` - $ gh issue comment 22 --body "I found a bug. Nothing works" + $ gh issue comment 22 --body "I was able to reproduce this issue, lets fix it." `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo opts.SelectorArg = args[0] - opts.Interactive = !(opts.Body != "" || webMode || editorMode) - if !opts.IO.CanPrompt() && opts.Interactive { - return &cmdutil.FlagError{Err: errors.New("--body, --editor, or --web required when not running interactively")} + inputFlags := 0 + if cmd.Flags().Changed("body") { + opts.InputType = inline + inputFlags++ + } + if webMode { + opts.InputType = web + inputFlags++ + } + if editorMode { + opts.InputType = editor + inputFlags++ } - if !opts.Interactive { - if opts.Body != "" && !webMode && !editorMode { - opts.InputType = inline - } else if opts.Body == "" && webMode && !editorMode { - opts.InputType = web - } else if opts.Body == "" && !webMode && editorMode { - opts.InputType = editor - } else { - return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")} + if inputFlags == 0 { + if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")} } + opts.Interactive = true + } else if inputFlags == 1 { + if !opts.IO.CanPrompt() && opts.InputType == editor { + return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")} + } + } else if inputFlags > 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")} } if runF != nil { @@ -120,30 +130,22 @@ func commentRun(opts *CommentOptions) error { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) - case inline: - if opts.Body != "" { - break - } - body, err := inlineSurvey() - if err != nil { - return err - } - opts.Body = body case editor: editorCommand, err := cmdutil.DetermineEditor(opts.Config) if err != nil { return err } - var body string - if opts.Interactive { - body, err = editorSurvey(editorCommand) - } else { - body, err = opts.Edit(editorCommand) - } + body, err := opts.Edit(editorCommand) if err != nil { return err } opts.Body = body + if opts.Interactive { + cs := opts.IO.ColorScheme() + fmt.Fprint(opts.IO.Out, cs.GreenBold("?")) + fmt.Fprint(opts.IO.Out, cs.Bold(" Body ")) + fmt.Fprint(opts.IO.Out, cs.Cyan("\n")) + } } if opts.Interactive { @@ -160,14 +162,11 @@ func commentRun(opts *CommentOptions) error { "subjectId": issue.ID, "body": opts.Body, } - url, err := api.CommentCreate(apiClient, baseRepo.RepoHost(), params) if err != nil { return err } - - fmt.Fprint(opts.IO.Out, url) - + fmt.Fprintln(opts.IO.Out, url) return nil } @@ -175,35 +174,12 @@ func inputTypeSurvey() (int, error) { var inputType int inputTypeQuestion := &survey.Select{ Message: "Where do you want to draft your comment?", - Options: []string{"In line", "Editor", "Web"}, + Options: []string{"Editor", "Web"}, } err := prompt.SurveyAskOne(inputTypeQuestion, &inputType) return inputType, err } -func inlineSurvey() (string, error) { - var body string - bodyQuestion := &survey.Input{ - Message: "Body", - } - err := prompt.SurveyAskOne(bodyQuestion, &body) - return body, err -} - -func editorSurvey(editorCommand string) (string, error) { - var body string - bodyQuestion := &surveyext.GhEditor{ - BlankAllowed: false, - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Body", - FileName: "*.md", - }, - } - err := prompt.SurveyAskOne(bodyQuestion, &body) - return body, err -} - func confirmSubmitSurvey() (bool, error) { var confirm bool submit := &survey.Confirm{ diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index e601ffbb8..cbca6eaab 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -182,19 +182,9 @@ func Test_commentRun(t *testing.T) { Interactive: true, InputType: 0, Body: "", + Edit: func(string) (string, error) { return "comment body", nil }, }, - stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", - }, - { - name: "interactive inline", - testInputType: inline, - input: &CommentOptions{ - SelectorArg: "123", - Interactive: true, - InputType: 0, - Body: "", - }, - stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + stdout: "? Body \nhttps://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, { name: "non-interactive web", @@ -217,7 +207,7 @@ func Test_commentRun(t *testing.T) { Body: "", Edit: func(string) (string, error) { return "comment body", nil }, }, - stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, { name: "non-interactive inline", @@ -228,7 +218,7 @@ func Test_commentRun(t *testing.T) { InputType: inline, Body: "comment body", }, - stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456", + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, } for _, tt := range tests { @@ -260,9 +250,7 @@ func Test_commentRun(t *testing.T) { defer teardown() // Input type select as.StubOne(tt.testInputType) - if tt.testInputType != web { - // Comment body - as.StubOne("comment body") + if tt.testInputType == editor { // Confirm submit as.StubOne(true) } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 972caab3d..6b3626f13 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -9,15 +9,16 @@ import ( ) var ( - magenta = ansi.ColorFunc("magenta") - cyan = ansi.ColorFunc("cyan") - red = ansi.ColorFunc("red") - yellow = ansi.ColorFunc("yellow") - blue = ansi.ColorFunc("blue") - green = ansi.ColorFunc("green") - gray = ansi.ColorFunc("black+h") - bold = ansi.ColorFunc("default+b") - cyanBold = ansi.ColorFunc("cyan+b") + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + green = ansi.ColorFunc("green") + gray = ansi.ColorFunc("black+h") + bold = ansi.ColorFunc("default+b") + cyanBold = ansi.ColorFunc("cyan+b") + greenBold = ansi.ColorFunc("green+b") gray256 = func(t string) string { return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) @@ -84,6 +85,13 @@ func (c *ColorScheme) Green(t string) string { return green(t) } +func (c *ColorScheme) GreenBold(t string) string { + if !c.enabled { + return t + } + return greenBold(t) +} + func (c *ColorScheme) Gray(t string) string { if !c.enabled { return t From de73b161784e791ac3b6093dd0d19c1235d5d30f Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 5 Jan 2021 10:39:07 -0800 Subject: [PATCH 109/129] Move CreateComment mutation --- api/queries_comments.go | 28 ++++++++++++++++++++++++++++ api/queries_issue.go | 28 ---------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index ccbabfe5d..69ba6abdb 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/internal/ghrepo" "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" ) type Comments struct { @@ -99,3 +100,30 @@ func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullReque return &Comments{Nodes: comments, TotalCount: len(comments)}, nil } + +func CommentCreate(client *Client, repoHost string, params map[string]string) (string, error) { + var mutation struct { + AddComment struct { + CommentEdge struct { + Node struct { + URL string + } + } + } `graphql:"addComment(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.AddCommentInput{ + Body: githubv4.String(params["body"]), + SubjectID: graphql.ID(params["subjectId"]), + }, + } + + gql := graphQLClient(client.http, repoHost) + err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables) + if err != nil { + return "", err + } + + return mutation.AddComment.CommentEdge.Node.URL, nil +} diff --git a/api/queries_issue.go b/api/queries_issue.go index 4c4c85b72..17f32eabd 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -10,7 +10,6 @@ import ( "github.com/cli/cli/internal/ghrepo" "github.com/shurcooL/githubv4" - "github.com/shurcooL/graphql" ) type IssuesPayload struct { @@ -124,33 +123,6 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } -func CommentCreate(client *Client, repoHost string, params map[string]string) (string, error) { - var mutation struct { - AddComment struct { - CommentEdge struct { - Node struct { - URL string - } - } - } `graphql:"addComment(input: $input)"` - } - - variables := map[string]interface{}{ - "input": githubv4.AddCommentInput{ - Body: githubv4.String(params["body"]), - SubjectID: graphql.ID(params["subjectId"]), - }, - } - - gql := graphQLClient(client.http, repoHost) - err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables) - if err != nil { - return "", err - } - - return mutation.AddComment.CommentEdge.Node.URL, nil -} - func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { type response struct { Repository struct { From 8b5c5896f2632e97270bc2d735fc541b640a08c3 Mon Sep 17 00:00:00 2001 From: xhqr <76452811+xhqr@users.noreply.github.com> Date: Tue, 5 Jan 2021 23:40:52 +0100 Subject: [PATCH 110/129] [repo/create] Create local repo dir with non tty. (#2671) This addresses issue #2587. --- pkg/cmd/repo/create/create.go | 25 ++++---- pkg/cmd/repo/create/create_test.go | 94 +++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index bdd2e4df5..8b5ab5b13 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -273,22 +273,26 @@ func createRun(opts *CreateOptions) error { if isTTY { fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), remoteURL) } - } else if opts.IO.CanPrompt() { - doSetup := createLocalDirectory - if !doSetup { - err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) - if err != nil { - return err + } else { + if opts.IO.CanPrompt() { + if !createLocalDirectory { + err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &createLocalDirectory) + if err != nil { + return err + } } } - if doSetup { + if createLocalDirectory { path := repo.Name gitInit, err := git.GitCommand("init", path) if err != nil { return err } - gitInit.Stdout = stdout + isTTY := opts.IO.IsStdoutTTY() + if isTTY { + gitInit.Stdout = stdout + } gitInit.Stderr = stderr err = run.PrepareCmd(gitInit).Run() if err != nil { @@ -304,8 +308,9 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - - fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", cs.SuccessIcon(), path) + if isTTY { + fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", cs.SuccessIcon(), path) + } } } diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 7282b350d..f9f3ea12d 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -3,6 +3,7 @@ package create import ( "bytes" "encoding/json" + "errors" "io/ioutil" "net/http" "os/exec" @@ -20,10 +21,10 @@ import ( "github.com/stretchr/testify/assert" ) -func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { +func runCommand(httpClient *http.Client, cli string, isTTY bool) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) fac := &cmdutil.Factory{ IOStreams: io, HttpClient: func() (*http.Client, error) { @@ -106,7 +107,7 @@ func TestRepoCreate(t *testing.T) { }, }) - output, err := runCommand(httpClient, "REPO") + output, err := runCommand(httpClient, "REPO", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -143,6 +144,83 @@ func TestRepoCreate(t *testing.T) { } } +func TestRepoCreate_outsideGitWorkDir(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/OWNER/REPO", + "name": "REPO", + "owner": { + "login": "OWNER" + } + } + } } }`)) + + httpClient := &http.Client{Transport: reg} + + var seenCmds []*exec.Cmd + cmdOutputs := []test.OutputStub{ + { + Error: errors.New("Not a git repository"), + }, + {}, + {}, + } + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + if len(cmdOutputs) == 0 { + t.Fatal("Too many calls to git command") + } + out := cmdOutputs[0] + cmdOutputs = cmdOutputs[1:] + seenCmds = append(seenCmds, cmd) + return &out + }) + defer restoreCmd() + + output, err := runCommand(httpClient, "REPO --private --confirm", false) + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String()) + assert.Equal(t, "", output.Stderr()) + + if len(seenCmds) != 3 { + t.Fatal("expected three commands to run") + } + + assert.Equal(t, "git rev-parse --show-toplevel", strings.Join(seenCmds[0].Args, " ")) + assert.Equal(t, "git init REPO", strings.Join(seenCmds[1].Args, " ")) + assert.Equal(t, "git -C REPO remote add origin https://github.com/OWNER/REPO.git", strings.Join(seenCmds[2].Args, " ")) + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(reg.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests)) + } + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" { + t.Errorf("expected %q, got %q", "REPO", repoName) + } + if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" { + t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility) + } + if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet { + t.Error("expected ownerId not to be set") + } +} + func TestRepoCreate_org(t *testing.T) { reg := &httpmock.Registry{} reg.Register( @@ -188,7 +266,7 @@ func TestRepoCreate_org(t *testing.T) { }, }) - output, err := runCommand(httpClient, "ORG/REPO") + output, err := runCommand(httpClient, "ORG/REPO", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -270,7 +348,7 @@ func TestRepoCreate_orgWithTeam(t *testing.T) { }, }) - output, err := runCommand(httpClient, "ORG/REPO --team monkeys") + output, err := runCommand(httpClient, "ORG/REPO --team monkeys", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -353,7 +431,7 @@ func TestRepoCreate_template(t *testing.T) { }, }) - output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'") + output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -441,7 +519,7 @@ func TestRepoCreate_withoutNameArg(t *testing.T) { }, }) - output, err := runCommand(httpClient, "") + output, err := runCommand(httpClient, "", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } From 155507d31d64322d31ae5ff43d8ec9cc3f327b6c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 5 Jan 2021 14:11:26 -0800 Subject: [PATCH 111/129] Make comment command easier to test --- pkg/cmd/issue/comment/comment.go | 56 ++++++++------ pkg/cmd/issue/comment/comment_test.go | 102 +++++++++++--------------- 2 files changed, 76 insertions(+), 82 deletions(-) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 5e73531fc..82099862a 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -13,18 +13,19 @@ import ( "github.com/cli/cli/pkg/cmd/issue/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" - "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) type CommentOptions struct { - HttpClient func() (*http.Client, error) - Config func() (config.Config, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Edit func(string) (string, error) + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + EditSurvey func() (string, error) + InputTypeSurvey func() (int, error) + ConfirmSubmitSurvey func() (bool, error) + OpenInBrowser func(string) error SelectorArg string Interactive bool @@ -40,12 +41,12 @@ const ( func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { opts := &CommentOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Edit: func(editorCommand string) (string, error) { - return surveyext.Edit(editorCommand, "*.md", "", f.IOStreams.In, f.IOStreams.Out, f.IOStreams.ErrOut, nil) - }, + IO: f.IOStreams, + HttpClient: f.HttpClient, + EditSurvey: editSurvey(f.Config, f.IOStreams), + InputTypeSurvey: inputTypeSurvey, + ConfirmSubmitSurvey: confirmSubmitSurvey, + OpenInBrowser: utils.OpenInBrowser, } var webMode bool @@ -96,6 +97,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra. return commentRun(opts) }, } + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") cmd.Flags().BoolVarP(&editorMode, "editor", "e", false, "Add body using editor") cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Add body in browser") @@ -116,7 +118,7 @@ func commentRun(opts *CommentOptions) error { } if opts.Interactive { - inputType, err := inputTypeSurvey() + inputType, err := opts.InputTypeSurvey() if err != nil { return err } @@ -129,13 +131,9 @@ func commentRun(opts *CommentOptions) error { if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } - return utils.OpenInBrowser(openURL) + return opts.OpenInBrowser(openURL) case editor: - editorCommand, err := cmdutil.DetermineEditor(opts.Config) - if err != nil { - return err - } - body, err := opts.Edit(editorCommand) + body, err := opts.EditSurvey() if err != nil { return err } @@ -149,7 +147,7 @@ func commentRun(opts *CommentOptions) error { } if opts.Interactive { - cont, err := confirmSubmitSurvey() + cont, err := opts.ConfirmSubmitSurvey() if err != nil { return err } @@ -170,22 +168,32 @@ func commentRun(opts *CommentOptions) error { return nil } -func inputTypeSurvey() (int, error) { +var inputTypeSurvey = func() (int, error) { var inputType int inputTypeQuestion := &survey.Select{ Message: "Where do you want to draft your comment?", Options: []string{"Editor", "Web"}, } - err := prompt.SurveyAskOne(inputTypeQuestion, &inputType) + err := survey.AskOne(inputTypeQuestion, &inputType) return inputType, err } -func confirmSubmitSurvey() (bool, error) { +var confirmSubmitSurvey = func() (bool, error) { var confirm bool submit := &survey.Confirm{ Message: "Submit?", Default: true, } - err := prompt.SurveyAskOne(submit, &confirm) + err := survey.AskOne(submit, &confirm) return confirm, err } + +var editSurvey = func(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { + return func() (string, error) { + editorCommand, err := cmdutil.DetermineEditor(cf) + if err != nil { + return "", err + } + return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil) + } +} diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index cbca6eaab..442f99298 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -3,17 +3,12 @@ package comment import ( "bytes" "net/http" - "os/exec" "testing" - "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -155,69 +150,89 @@ func TestNewCmdComment(t *testing.T) { func Test_commentRun(t *testing.T) { tests := []struct { - name string - input *CommentOptions - testInputType int - stdout string - stderr string - wantsErr bool - errMsg string + name string + input *CommentOptions + httpStubs func(*testing.T, *httpmock.Registry) + stdout string + stderr string }{ { - name: "interactive web", - testInputType: web, + name: "interactive web", input: &CommentOptions{ SelectorArg: "123", Interactive: true, InputType: 0, Body: "", + + InputTypeSurvey: func() (int, error) { return web, nil }, + OpenInBrowser: func(string) error { return nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) }, stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", }, { - name: "interactive editor", - testInputType: editor, + name: "interactive editor", input: &CommentOptions{ SelectorArg: "123", Interactive: true, InputType: 0, Body: "", - Edit: func(string) (string, error) { return "comment body", nil }, + + EditSurvey: func() (string, error) { return "comment body", nil }, + InputTypeSurvey: func() (int, error) { return editor, nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) }, stdout: "? Body \nhttps://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, { - name: "non-interactive web", - testInputType: web, + name: "non-interactive web", input: &CommentOptions{ SelectorArg: "123", Interactive: false, InputType: web, Body: "", + + OpenInBrowser: func(string) error { return nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) }, stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", }, { - name: "non-interactive editor", - testInputType: editor, + name: "non-interactive editor", input: &CommentOptions{ SelectorArg: "123", Interactive: false, InputType: editor, Body: "", - Edit: func(string) (string, error) { return "comment body", nil }, + + EditSurvey: func() (string, error) { return "comment body", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, { - name: "non-interactive inline", - testInputType: inline, + name: "non-interactive inline", input: &CommentOptions{ SelectorArg: "123", Interactive: false, InputType: inline, Body: "comment body", }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) + }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, } @@ -227,49 +242,20 @@ func Test_commentRun(t *testing.T) { io.SetStdinTTY(true) io.SetStderrTTY(true) - client := &httpmock.Registry{} - defer client.Verify(t) - mockIssueFromNumber(client) - if tt.testInputType != web { - mockCommentCreate(t, client) - } + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.httpStubs(t, reg) tt.input.IO = io tt.input.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: client}, nil - } - tt.input.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil + return &http.Client{Transport: reg}, nil } tt.input.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - if tt.input.Interactive { - as, teardown := prompt.InitAskStubber() - defer teardown() - // Input type select - as.StubOne(tt.testInputType) - if tt.testInputType == editor { - // Confirm submit - as.StubOne(true) - } - } - - if tt.testInputType == web { - // Stub browser open - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - return &test.OutputStub{} - }) - defer restoreCmd() - } - t.Run(tt.name, func(t *testing.T) { err := commentRun(tt.input) - if tt.wantsErr { - assert.EqualError(t, err, tt.errMsg) - return - } assert.NoError(t, err) assert.Equal(t, tt.stdout, stdout.String()) assert.Equal(t, tt.stderr, stderr.String()) @@ -277,7 +263,7 @@ func Test_commentRun(t *testing.T) { } } -func mockIssueFromNumber(reg *httpmock.Registry) { +func mockIssueFromNumber(_ *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` From 5e9777597825831777511c3f32351bcb751019e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 6 Jan 2021 18:08:02 +0100 Subject: [PATCH 112/129] Clarify building from source on Windows Address https://github.com/cli/cli/issues/2545#issuecomment-751842548 --- docs/source.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/source.md b/docs/source.md index 1c6a8470a..5a1a420f6 100644 --- a/docs/source.md +++ b/docs/source.md @@ -17,16 +17,23 @@ 2. Build and install + #### Unix-like systems ```sh # installs to '/usr/local' by default; sudo may be required $ make install - ``` - - To install to a different location: - ```sh + + # install to a different location $ make install prefix=/path/to/gh ``` - Make sure that the `${prefix}/bin` directory is in your PATH. + #### Windows + ```sh + # build the binary + > go build -o gh.exe ./cmd/gh + ``` + There is no install step available on Windows. 3. Run `gh version` to check if it worked. + + #### Windows + Run `.\gh version` to check if it worked. From 19ee0eff0831ce17d5d1979cb46869e3436f8e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 7 Jan 2021 16:16:44 +0100 Subject: [PATCH 113/129] Simplify linter output setup `golangci-lint` now supports an output formatter for GitHub Actions, so we don't need to manually reformat the failure output anymore. --- .github/workflows/lint.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4dc95a4f1..3ba3b3f25 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: go mod verify go mod download - LINT_VERSION=1.29.0 + LINT_VERSION=1.34.1 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ @@ -50,10 +50,6 @@ jobs: assert-nothing-changed go fmt ./... assert-nothing-changed go mod tidy - while read -r file linter msg; do - IFS=: read -ra f <<<"$file" - printf '::error file=%s,line=%s,col=%s::%s\n' "${f[0]}" "${f[1]}" "${f[2]}" "[$linter] $msg" - STATUS=1 - done < <(bin/golangci-lint run --out-format tab) + bin/golangci-lint run --out-format github-actions || STATUS=$? exit $STATUS From 723e9e31baaee1e3cbb35841cb07b16527542b43 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 11 Jan 2021 13:56:17 -0800 Subject: [PATCH 114/129] Address PR comments --- api/queries_comments.go | 11 ++++-- pkg/cmd/issue/comment/comment.go | 51 ++++++++++++++------------- pkg/cmd/issue/comment/comment_test.go | 18 +++++----- pkg/iostreams/color.go | 26 +++++--------- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index 69ba6abdb..17a7a9f44 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -101,7 +101,12 @@ func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullReque return &Comments{Nodes: comments, TotalCount: len(comments)}, nil } -func CommentCreate(client *Client, repoHost string, params map[string]string) (string, error) { +type CommentCreateInput struct { + Body string + SubjectId string +} + +func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) { var mutation struct { AddComment struct { CommentEdge struct { @@ -114,8 +119,8 @@ func CommentCreate(client *Client, repoHost string, params map[string]string) (s variables := map[string]interface{}{ "input": githubv4.AddCommentInput{ - Body: githubv4.String(params["body"]), - SubjectID: graphql.ID(params["subjectId"]), + Body: githubv4.String(params.Body), + SubjectID: graphql.ID(params.SubjectId), }, } diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 82099862a..3c6be4812 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -23,20 +23,22 @@ type CommentOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) EditSurvey func() (string, error) - InputTypeSurvey func() (int, error) + InputTypeSurvey func() (inputType, error) ConfirmSubmitSurvey func() (bool, error) OpenInBrowser func(string) error SelectorArg string Interactive bool - InputType int + InputType inputType Body string } +type inputType int + const ( - editor = iota - web - inline + inputTypeEditor inputType = iota + inputTypeInline + inputTypeWeb ) func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { @@ -66,15 +68,15 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra. inputFlags := 0 if cmd.Flags().Changed("body") { - opts.InputType = inline + opts.InputType = inputTypeInline inputFlags++ } if webMode { - opts.InputType = web + opts.InputType = inputTypeWeb inputFlags++ } if editorMode { - opts.InputType = editor + opts.InputType = inputTypeEditor inputFlags++ } @@ -84,7 +86,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra. } opts.Interactive = true } else if inputFlags == 1 { - if !opts.IO.CanPrompt() && opts.InputType == editor { + if !opts.IO.CanPrompt() && opts.InputType == inputTypeEditor { return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")} } } else if inputFlags > 1 { @@ -126,24 +128,18 @@ func commentRun(opts *CommentOptions) error { } switch opts.InputType { - case web: + case inputTypeWeb: openURL := issue.URL + "#issuecomment-new" if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return opts.OpenInBrowser(openURL) - case editor: + case inputTypeEditor: body, err := opts.EditSurvey() if err != nil { return err } opts.Body = body - if opts.Interactive { - cs := opts.IO.ColorScheme() - fmt.Fprint(opts.IO.Out, cs.GreenBold("?")) - fmt.Fprint(opts.IO.Out, cs.Bold(" Body ")) - fmt.Fprint(opts.IO.Out, cs.Cyan("\n")) - } } if opts.Interactive { @@ -156,10 +152,7 @@ func commentRun(opts *CommentOptions) error { } } - params := map[string]string{ - "subjectId": issue.ID, - "body": opts.Body, - } + params := api.CommentCreateInput{Body: opts.Body, SubjectId: issue.ID} url, err := api.CommentCreate(apiClient, baseRepo.RepoHost(), params) if err != nil { return err @@ -168,14 +161,22 @@ func commentRun(opts *CommentOptions) error { return nil } -var inputTypeSurvey = func() (int, error) { - var inputType int +var inputTypeSurvey = func() (inputType, error) { + var result int inputTypeQuestion := &survey.Select{ Message: "Where do you want to draft your comment?", Options: []string{"Editor", "Web"}, } - err := survey.AskOne(inputTypeQuestion, &inputType) - return inputType, err + err := survey.AskOne(inputTypeQuestion, &result) + if err != nil { + return 0, err + } + + if result == 0 { + return inputTypeEditor, nil + } else { + return inputTypeWeb, nil + } } var confirmSubmitSurvey = func() (bool, error) { diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 442f99298..b10e20c54 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -54,7 +54,7 @@ func TestNewCmdComment(t *testing.T) { output: CommentOptions{ SelectorArg: "1", Interactive: false, - InputType: inline, + InputType: inputTypeInline, Body: "test", }, wantsErr: false, @@ -65,7 +65,7 @@ func TestNewCmdComment(t *testing.T) { output: CommentOptions{ SelectorArg: "1", Interactive: false, - InputType: editor, + InputType: inputTypeEditor, Body: "", }, wantsErr: false, @@ -76,7 +76,7 @@ func TestNewCmdComment(t *testing.T) { output: CommentOptions{ SelectorArg: "1", Interactive: false, - InputType: web, + InputType: inputTypeWeb, Body: "", }, wantsErr: false, @@ -164,7 +164,7 @@ func Test_commentRun(t *testing.T) { InputType: 0, Body: "", - InputTypeSurvey: func() (int, error) { return web, nil }, + InputTypeSurvey: func() (inputType, error) { return inputTypeWeb, nil }, OpenInBrowser: func(string) error { return nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -181,21 +181,21 @@ func Test_commentRun(t *testing.T) { Body: "", EditSurvey: func() (string, error) { return "comment body", nil }, - InputTypeSurvey: func() (int, error) { return editor, nil }, + InputTypeSurvey: func() (inputType, error) { return inputTypeEditor, nil }, ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockIssueFromNumber(t, reg) mockCommentCreate(t, reg) }, - stdout: "? Body \nhttps://github.com/OWNER/REPO/issues/123#issuecomment-456\n", + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, { name: "non-interactive web", input: &CommentOptions{ SelectorArg: "123", Interactive: false, - InputType: web, + InputType: inputTypeWeb, Body: "", OpenInBrowser: func(string) error { return nil }, @@ -210,7 +210,7 @@ func Test_commentRun(t *testing.T) { input: &CommentOptions{ SelectorArg: "123", Interactive: false, - InputType: editor, + InputType: inputTypeEditor, Body: "", EditSurvey: func() (string, error) { return "comment body", nil }, @@ -226,7 +226,7 @@ func Test_commentRun(t *testing.T) { input: &CommentOptions{ SelectorArg: "123", Interactive: false, - InputType: inline, + InputType: inputTypeInline, Body: "comment body", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 6b3626f13..972caab3d 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -9,16 +9,15 @@ import ( ) var ( - magenta = ansi.ColorFunc("magenta") - cyan = ansi.ColorFunc("cyan") - red = ansi.ColorFunc("red") - yellow = ansi.ColorFunc("yellow") - blue = ansi.ColorFunc("blue") - green = ansi.ColorFunc("green") - gray = ansi.ColorFunc("black+h") - bold = ansi.ColorFunc("default+b") - cyanBold = ansi.ColorFunc("cyan+b") - greenBold = ansi.ColorFunc("green+b") + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + green = ansi.ColorFunc("green") + gray = ansi.ColorFunc("black+h") + bold = ansi.ColorFunc("default+b") + cyanBold = ansi.ColorFunc("cyan+b") gray256 = func(t string) string { return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) @@ -85,13 +84,6 @@ func (c *ColorScheme) Green(t string) string { return green(t) } -func (c *ColorScheme) GreenBold(t string) string { - if !c.enabled { - return t - } - return greenBold(t) -} - func (c *ColorScheme) Gray(t string) string { if !c.enabled { return t From ce151420f3ef3e12fa6c0a656aa17515ea62b0dd Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Mon, 11 Jan 2021 21:07:19 -0300 Subject: [PATCH 115/129] Migrate legacy tests --- api/client_test.go | 28 ++- api/queries_issue_test.go | 134 ++++++----- internal/update/update_test.go | 13 +- pkg/cmd/gist/delete/delete_test.go | 7 +- pkg/cmd/issue/close/close_test.go | 50 ++-- pkg/cmd/issue/create/create_test.go | 135 +++++------ pkg/cmd/issue/list/list_test.go | 26 +- pkg/cmd/issue/reopen/reopen_test.go | 50 ++-- pkg/cmd/issue/view/view_test.go | 73 +++--- pkg/cmd/pr/checks/checks_test.go | 22 +- pkg/cmd/pr/close/close_test.go | 55 +++-- pkg/cmd/pr/create/create_test.go | 65 +++-- pkg/cmd/pr/diff/diff_test.go | 83 ++++--- pkg/cmd/pr/ready/ready_test.go | 44 ++-- pkg/cmd/pr/reopen/reopen_test.go | 46 ++-- pkg/cmd/pr/review/review_test.go | 360 +++++++++++++--------------- pkg/cmd/pr/view/view_test.go | 71 +++--- pkg/cmd/repo/create/http_test.go | 29 +-- pkg/httpmock/legacy.go | 7 - 19 files changed, 699 insertions(+), 599 deletions(-) diff --git a/api/client_test.go b/api/client_test.go index 35a45af8c..4c272aa58 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -32,7 +32,11 @@ func TestGraphQL(t *testing.T) { } }{} - http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`)) + http.Register( + httpmock.GraphQL("QUERY"), + httpmock.StringResponse(`{"data":{"viewer":{"login":"hubot"}}}`), + ) + err := client.GraphQL("github.com", "QUERY", vars, &response) eq(t, err, nil) eq(t, response.Viewer.Login, "hubot") @@ -48,12 +52,17 @@ func TestGraphQLError(t *testing.T) { client := NewClient(ReplaceTripper(http)) response := struct{}{} - http.StubResponse(200, bytes.NewBufferString(` - { "errors": [ - {"message":"OH NO"}, - {"message":"this is fine"} - ] - }`)) + + http.Register( + httpmock.GraphQL(""), + httpmock.StringResponse(` + { "errors": [ + {"message":"OH NO"}, + {"message":"this is fine"} + ] + } + `), + ) err := client.GraphQL("github.com", "", nil, &response) if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" { @@ -68,7 +77,10 @@ func TestRESTGetDelete(t *testing.T) { ReplaceTripper(http), ) - http.StubResponse(204, bytes.NewBuffer([]byte{})) + http.Register( + httpmock.REST("DELETE", "applications/CLIENTID/grant"), + httpmock.StatusStringResponse(204, "{}"), + ) r := bytes.NewReader([]byte(`{}`)) err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go index 83dee55b7..e6aca1907 100644 --- a/api/queries_issue_test.go +++ b/api/queries_issue_test.go @@ -1,7 +1,6 @@ package api import ( - "bytes" "encoding/json" "io/ioutil" "testing" @@ -16,30 +15,36 @@ func TestIssueList(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [], - "pageInfo": { - "hasNextPage": true, - "endCursor": "ENDCURSOR" - } - } - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [], - "pageInfo": { - "hasNextPage": false, - "endCursor": "ENDCURSOR" - } - } - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [], + "pageInfo": { + "hasNextPage": true, + "endCursor": "ENDCURSOR" + } + } + } } } + `), + ) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": "ENDCURSOR" + } + } + } } } + `), + ) repo, _ := ghrepo.FromFullName("OWNER/REPO") _, err := IssueList(client, repo, "open", []string{}, "", 251, "", "", "") @@ -75,44 +80,51 @@ func TestIssueList_pagination(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [ - { - "title": "issue1", - "labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 }, - "assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 } + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [ + { + "title": "issue1", + "labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 }, + "assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 } + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "ENDCURSOR" + }, + "totalCount": 2 } - ], - "pageInfo": { - "hasNextPage": true, - "endCursor": "ENDCURSOR" - }, - "totalCount": 2 - } - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [ - { - "title": "issue2", - "labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 }, - "assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 } + } } } + `), + ) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [ + { + "title": "issue2", + "labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 }, + "assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "ENDCURSOR" + }, + "totalCount": 2 } - ], - "pageInfo": { - "hasNextPage": false, - "endCursor": "ENDCURSOR" - }, - "totalCount": 2 - } - } } } - `)) + } } } + `), + ) repo := ghrepo.New("OWNER", "REPO") res, err := IssueList(client, repo, "", nil, "", 0, "", "", "") diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 2fcb2d6ab..024122983 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -1,7 +1,6 @@ package update import ( - "bytes" "fmt" "io/ioutil" "log" @@ -54,10 +53,14 @@ func TestCheckForUpdate(t *testing.T) { t.Run(s.Name, func(t *testing.T) { http := &httpmock.Registry{} client := api.NewClient(api.ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{ - "tag_name": "%s", - "html_url": "%s" - }`, s.LatestVersion, s.LatestURL))) + + http.Register( + httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), + httpmock.StringResponse(fmt.Sprintf(`{ + "tag_name": "%s", + "html_url": "%s" + }`, s.LatestVersion, s.LatestURL)), + ) rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) if err != nil { diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go index 682a92d50..21480803c 100644 --- a/pkg/cmd/gist/delete/delete_test.go +++ b/pkg/cmd/gist/delete/delete_test.go @@ -2,6 +2,9 @@ package delete import ( "bytes" + "net/http" + "testing" + "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" @@ -9,8 +12,6 @@ import ( "github.com/cli/cli/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" - "net/http" - "testing" ) func TestNewCmdDelete(t *testing.T) { @@ -98,7 +99,7 @@ func Test_deleteRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("DELETE", "gists/1234"), - httpmock.StatusStringResponse(200, "{}")) + httpmock.StringResponse("{}")) }, wantErr: false, }, diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index eadf7f440..5a2054309 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,14 +59,21 @@ func TestIssueClose(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["issueId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "13") if err != nil { @@ -83,12 +91,14 @@ func TestIssueClose_alreadyClosed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue", "closed": true} - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue", "closed": true} + } } }`), + ) output, err := runCommand(http, true, "13") if err != nil { @@ -106,11 +116,13 @@ func TestIssueClose_issuesDisabled(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "13") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index d7e6b4ec0..21177164a 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -94,39 +94,32 @@ func TestIssueCreate(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["repositoryId"], "REPOID") + assert.Equal(t, inputs["title"], "hello") + assert.Equal(t, inputs["body"], "cash rules everything around me") + }), + ) output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`) if err != nil { t.Errorf("error running command `issue create`: %v", err) } - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "hello") - eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } @@ -134,12 +127,13 @@ func TestIssueCreate_recover(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) http.Register( httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), httpmock.StringResponse(` @@ -214,17 +208,26 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["repositoryId"], "REPOID") + assert.Equal(t, inputs["title"], "hello") + assert.Equal(t, inputs["body"], "I have a suggestion for an enhancement") + }), + ) as, teardown := prompt.InitAskStubber() defer teardown() @@ -256,22 +259,6 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "hello") - eq(t, reqBody.Variables.Input.Body, "I have a suggestion for an enhancement") - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } @@ -279,12 +266,14 @@ func TestIssueCreate_continueInBrowser(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) as, teardown := prompt.InitAskStubber() defer teardown() @@ -413,12 +402,14 @@ func TestIssueCreate_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, `-t heres -b johnny`) if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index ff202b6f3..456741a91 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -169,12 +169,14 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } }`), + ) _, err := runCommand(http, true, "") if err != nil { @@ -197,11 +199,13 @@ func TestIssueList_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go index df8e8ecd7..bce4bc882 100644 --- a/pkg/cmd/issue/reopen/reopen_test.go +++ b/pkg/cmd/issue/reopen/reopen_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,14 +59,21 @@ func TestIssueReopen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": true, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 2, "closed": true, "title": "The title of the issue"} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueReopen\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["issueId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "2") if err != nil { @@ -83,12 +91,14 @@ func TestIssueReopen_alreadyOpen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": false, "title": "The title of the issue"} - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": false, "title": "The title of the issue"} + } } }`), + ) output, err := runCommand(http, true, "2") if err != nil { @@ -106,11 +116,13 @@ func TestIssueReopen_issuesDisabled(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "2") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 2b94c5a70..82890eac9 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -63,12 +63,15 @@ func TestIssueView_web(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -96,12 +99,15 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -265,11 +271,14 @@ func TestIssueView_web_notFound(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "errors": [ - { "message": "Could not resolve to an Issue with the number of 9999." } - ] } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "errors": [ + { "message": "Could not resolve to an Issue with the number of 9999." } + ] } + `), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -292,12 +301,15 @@ func TestIssueView_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `), + ) _, err := runCommand(http, true, `6666`) if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { @@ -309,12 +321,15 @@ func TestIssueView_web_urlArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 4e31eade6..64565a963 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -88,11 +88,13 @@ func Test_checksRun(t *testing.T) { { name: "no checks", stubs: func(reg *httpmock.Registry) { - reg.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } - `)) + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } + } } } + `)) }, wantOut: "", wantErr: "no checks reported on the 'master' branch", @@ -125,10 +127,12 @@ func Test_checksRun(t *testing.T) { name: "no checks", nontty: true, stubs: func(reg *httpmock.Registry) { - reg.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } + } } } `)) }, wantOut: "", diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 136554b4f..3154b4e4b 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -61,13 +62,20 @@ func TestPrClose(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR" } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR" } + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "96") if err != nil { @@ -85,11 +93,13 @@ func TestPrClose_alreadyClosed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } + } } }`), + ) output, err := runCommand(http, true, "101") if err != nil { @@ -106,12 +116,21 @@ func TestPrClose_alreadyClosed(t *testing.T) { func TestPrClose_deleteBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR", "headRefName":"blueberries", "headRepositoryOwner": {"login": "OWNER"}} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + 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"}} + } } }`), + ) + 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/blueberries"), httpmock.StringResponse(`{}`)) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 171207ea8..63963aa77 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -233,15 +233,26 @@ func TestPRCreate_nontty(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, + func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"]) + assert.Equal(t, "my title", input["title"]) + assert.Equal(t, "my body", input["body"]) + assert.Equal(t, "master", input["baseRefName"]) + assert.Equal(t, "feature", input["headRefName"]) + }), + ) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -252,26 +263,6 @@ func TestPRCreate_nontty(t *testing.T) { output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body" -H feature`) require.NoError(t, err) - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID) - assert.Equal(t, "my title", reqBody.Variables.Input.Title) - assert.Equal(t, "my body", reqBody.Variables.Input.Body) - assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName) - assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName) - assert.Equal(t, "", output.Stderr()) assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } @@ -661,13 +652,15 @@ func TestPRCreate_alreadyExists(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "headRefName": "feature", - "baseRefName": "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" } + ] } } } }`), + ) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 3e0b23adc..6b6af4eaa 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -164,9 +164,15 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s func TestPRDiff_no_current_pr(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [] } } } } - `)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequests": { "nodes": [] } + } } }`), + ) + _, err := runCommand(http, nil, false, "") if err == nil { t.Fatal("expected error") @@ -177,12 +183,19 @@ func TestPRDiff_no_current_pr(t *testing.T) { func TestPRDiff_argument_not_found(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } -`)) - http.StubResponse(404, bytes.NewBufferString("")) + + 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") if err == nil { t.Fatal("expected error", err) @@ -193,15 +206,23 @@ func TestPRDiff_argument_not_found(t *testing.T) { func TestPRDiff_notty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) + + 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.REST("GET", "repos/OWNER/REPO/pulls/123"), + httpmock.StringResponse(testDiff), + ) + output, err := runCommand(http, nil, false, "") if err != nil { t.Fatalf("unexpected error: %s", err) @@ -214,15 +235,23 @@ func TestPRDiff_notty(t *testing.T) { func TestPRDiff_tty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) + + 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.REST("GET", "repos/OWNER/REPO/pulls/123"), + httpmock.StringResponse(testDiff), + ) + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatalf("unexpected error: %s", err) diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go index 4dc5d9eee..caf2c7e5f 100644 --- a/pkg/cmd/pr/ready/ready_test.go +++ b/pkg/cmd/pr/ready/ready_test.go @@ -143,12 +143,20 @@ func TestPRReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 444, "closed": false, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 444, "closed": false, "isDraft": true} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReadyForReview\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "444") if err != nil { @@ -166,11 +174,13 @@ func TestPRReady_alreadyReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 445, "closed": false, "isDraft": false} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 445, "closed": false, "isDraft": false} + } } }`), + ) output, err := runCommand(http, true, "445") if err != nil { @@ -188,11 +198,13 @@ func TestPRReady_closed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 446, "closed": true, "isDraft": true} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 446, "closed": true, "isDraft": true} + } } }`), + ) output, err := runCommand(http, true, "446") if err == nil { diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go index 24dfa488f..19c4a1e6a 100644 --- a/pkg/cmd/pr/reopen/reopen_test.go +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,13 +59,20 @@ func TestPRReopen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 666, "title": "The title of the PR", "closed": true} + } } }`), + ) + 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, "666") if err != nil { @@ -82,11 +90,13 @@ func TestPRReopen_alreadyOpen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} + } } }`), + ) output, err := runCommand(http, true, "666") if err != nil { @@ -104,11 +114,13 @@ func TestPRReopen_alreadyMerged(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} + } } }`), + ) output, err := runCommand(http, true, "666") if err == nil { diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 2184c264c..749495c7a 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -2,7 +2,6 @@ package review import ( "bytes" - "encoding/json" "io/ioutil" "net/http" "regexp" @@ -183,24 +182,36 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s func TestPRReview_url_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + 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 { @@ -208,45 +219,41 @@ func TestPRReview_url_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_number_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + 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 { @@ -254,36 +261,32 @@ func TestPRReview_number_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_no_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + 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 { @@ -291,22 +294,6 @@ func TestPRReview_no_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event) - assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview(t *testing.T) { @@ -326,34 +313,30 @@ func TestPRReview(t *testing.T) { t.Run(kase.Cmd, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` + + 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" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + ] } } } }`), + ) + 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) + }), + ) _, err := runCommand(http, nil, false, kase.Cmd) if err != nil { t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err) } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, kase.ExpectedEvent, reqBody.Variables.Input.Event) - assert.Equal(t, kase.ExpectedBody, reqBody.Variables.Input.Body) }) } } @@ -361,17 +344,27 @@ func TestPRReview(t *testing.T) { func TestPRReview_nontty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + 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) @@ -379,35 +372,32 @@ func TestPRReview_nontty(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, "", output.Stderr()) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event) - assert.Equal(t, "cool", reqBody.Variables.Input.Body) } func TestPRReview_interactive(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + 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"], "APPROVE") + assert.Equal(t, inputs["body"], "cool story") + }), + ) + as, teardown := prompt.InitAskStubber() defer teardown() @@ -440,33 +430,22 @@ func TestPRReview_interactive(t *testing.T) { test.ExpectLines(t, output.String(), "Got:", "cool.*story") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview_interactive_no_body(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) + + 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" } + ] } } } }`), + ) as, teardown := prompt.InitAskStubber() defer teardown() @@ -500,16 +479,27 @@ func TestPRReview_interactive_no_body(t *testing.T) { func TestPRReview_interactive_blank_approve(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + 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"], "APPROVE") + assert.Equal(t, inputs["body"], "") + }), + ) + as, teardown := prompt.InitAskStubber() defer teardown() @@ -543,18 +533,4 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 678629831..e61ccd614 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -553,11 +553,13 @@ func TestPRView_web_numberArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -584,11 +586,13 @@ func TestPRView_web_numberArgWithHash(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -614,11 +618,14 @@ func TestPRView_web_numberArgWithHash(t *testing.T) { func TestPRView_web_urlArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -645,13 +652,15 @@ func TestPRView_web_branchArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": false, - "url": "https://github.com/OWNER/REPO/pull/23" } - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": false, + "url": "https://github.com/OWNER/REPO/pull/23" } + ] } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -678,14 +687,16 @@ func TestPRView_web_branchWithOwnerArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": true, - "headRepositoryOwner": { "login": "hubot" }, - "url": "https://github.com/hubot/REPO/pull/23" } - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": true, + "headRepositoryOwner": { "login": "hubot" }, + "url": "https://github.com/hubot/REPO/pull/23" } + ] } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go index 360403538..240a1b625 100644 --- a/pkg/cmd/repo/create/http_test.go +++ b/pkg/cmd/repo/create/http_test.go @@ -1,20 +1,25 @@ package create import ( - "bytes" - "encoding/json" - "io/ioutil" "testing" "github.com/cli/cli/api" "github.com/cli/cli/pkg/httpmock" + "github.com/stretchr/testify/assert" ) func Test_RepoCreate(t *testing.T) { reg := &httpmock.Registry{} httpClient := api.NewHTTPClient(api.ReplaceTripper(reg)) - reg.StubResponse(200, bytes.NewBufferString(`{}`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.GraphQLMutation(`{}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["description"], "roasted chestnuts") + assert.Equal(t, inputs["homepageUrl"], "http://example.com") + }), + ) input := repoCreateInput{ Description: "roasted chestnuts", @@ -29,20 +34,4 @@ func Test_RepoCreate(t *testing.T) { if len(reg.Requests) != 1 { t.Fatalf("expected 1 HTTP request, seen %d", len(reg.Requests)) } - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if description := reqBody.Variables.Input["description"].(string); description != "roasted chestnuts" { - t.Errorf("expected description to be %q, got %q", "roasted chestnuts", description) - } - if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { - t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) - } } diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go index c6f9e56db..071876cc1 100644 --- a/pkg/httpmock/legacy.go +++ b/pkg/httpmock/legacy.go @@ -2,19 +2,12 @@ package httpmock import ( "fmt" - "io" "net/http" "os" ) // TODO: clean up methods in this file when there are no more callers -func (r *Registry) StubResponse(status int, body io.Reader) { - r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { - return httpResponse(status, req, body), nil - }) -} - func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() { fixtureFile, err := os.Open(fixturePath) r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { From 3ab01661e4c4baa03658d0328c10537a327d03a5 Mon Sep 17 00:00:00 2001 From: Robin Neatherway Date: Wed, 13 Jan 2021 11:09:00 +0000 Subject: [PATCH 116/129] Add on: pull_request trigger to CodeQL workflow From February 2021, in order to provide feedback on pull requests, Code Scanning workflows must be configured with both `push` and `pull_request` triggers. This is because Code Scanning compares the results from a pull request against the results for the base branch to tell you only what has changed between the two. Early in the beta period we supported displaying results on pull requests for workflows with only `push` triggers, but have discontinued support as this proved to be less robust. See https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#scanning-pull-requests for more information on how best to configure your Code Scanning workflows. --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28d17464b..1bf4d7a72 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,6 +2,7 @@ name: Code Scanning on: push: + pull_request: schedule: - cron: "0 0 * * 0" From 85e0e44920181cd411ac5251bb944e6c6837a6a5 Mon Sep 17 00:00:00 2001 From: Devon Romanko <28825608+dpromanko@users.noreply.github.com> Date: Fri, 15 Jan 2021 07:25:24 -0500 Subject: [PATCH 117/129] Add prompt to delete local branch when attempting to merge a PR that is already merged --- pkg/cmd/pr/merge/merge.go | 75 +++++++++++++++++++--------------- pkg/cmd/pr/merge/merge_test.go | 28 +++++++++---- 2 files changed, 61 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index abd761807..f282ae5c8 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -123,49 +123,58 @@ func mergeRun(opts *MergeOptions) error { if pr.Mergeable == "CONFLICTING" { err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", cs.Red("!"), pr.Number, pr.Title) return err - } else if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) was already merged", cs.Red("!"), pr.Number, pr.Title) - return err } - mergeMethod := opts.MergeMethod deleteBranch := opts.DeleteBranch crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() + isTerminal := opts.IO.IsStdoutTTY() + isPRAlreadyMerged := pr.State == "MERGED" - if opts.InteractiveMode { - mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) - if err != nil { - if errors.Is(err, cancelError) { - fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") - return cmdutil.SilentError + if !isPRAlreadyMerged { + mergeMethod := opts.MergeMethod + + if opts.InteractiveMode { + mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) + if err != nil { + if errors.Is(err, cancelError) { + fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") + return cmdutil.SilentError + } + return err } + } + + var action string + if mergeMethod == api.PullRequestMergeMethodRebase { + action = "Rebased and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) + } else if mergeMethod == api.PullRequestMergeMethodSquash { + action = "Squashed and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) + } else if mergeMethod == api.PullRequestMergeMethodMerge { + action = "Merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) + } else { + err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) return err } - } - var action string - if mergeMethod == api.PullRequestMergeMethodRebase { - action = "Rebased and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) - } else if mergeMethod == api.PullRequestMergeMethodSquash { - action = "Squashed and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) - } else if mergeMethod == api.PullRequestMergeMethodMerge { - action = "Merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) + } } else { - err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) - return err - } + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Pull request #%d (%s) was already merged. Would you like to delete this local branch and switch to the default branch?", pr.Number, pr.Title), + Default: false, + }, &deleteBranch) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - isTerminal := opts.IO.IsStdoutTTY() - - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } } if deleteBranch { @@ -203,7 +212,7 @@ func mergeRun(opts *MergeOptions) error { } } - if !crossRepoPR { + if !isPRAlreadyMerged && !crossRepoPR { err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) var httpErr api.HTTPError // The ref might have already been deleted by GitHub diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 6cd0e19dc..ee57020f3 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -485,7 +485,17 @@ func TestPrMerge_alreadyMerged(t *testing.T) { httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { - "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} + "pullRequest": { + "number": 4, + "title": "The title of the PR", + "state": "MERGED", + "baseRefName": "master", + "headRefName": "blueberries", + "headRepositoryOwner": { + "login": "OWNER" + }, + "isCrossRepository": false + } } } }`)) cs, cmdTeardown := test.InitCmdStubber() @@ -496,16 +506,16 @@ func TestPrMerge_alreadyMerged(t *testing.T) { cs.Stub("") // git checkout master cs.Stub("") // git branch -d - output, err := runCommand(http, "master", true, "pr merge 4") - if err == nil { - t.Fatalf("expected an error running command `pr merge`: %v", err) + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + 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) } - r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + test.ExpectLines(t, output.Stderr(), "✔ Deleted branch blueberries and switched to branch master") } func TestPRMerge_interactive(t *testing.T) { From 2c35eb04ff7a454c19e83014510db9c1087aa6f5 Mon Sep 17 00:00:00 2001 From: Devon Romanko <28825608+dpromanko@users.noreply.github.com> Date: Fri, 15 Jan 2021 16:54:46 -0500 Subject: [PATCH 118/129] address pr comments --- pkg/cmd/pr/merge/merge.go | 12 +++++++++--- pkg/cmd/pr/merge/merge_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index f282ae5c8..83dd883c6 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -125,10 +125,16 @@ func mergeRun(opts *MergeOptions) error { return err } + isPRAlreadyMerged := pr.State == "MERGED" + + if isPRAlreadyMerged && !opts.InteractiveMode && !opts.DeleteBranch { + err := fmt.Errorf("%s Pull request #%d (%s) was already merged", cs.Red("!"), pr.Number, pr.Title) + return err + } + deleteBranch := opts.DeleteBranch crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() isTerminal := opts.IO.IsStdoutTTY() - isPRAlreadyMerged := pr.State == "MERGED" if !isPRAlreadyMerged { mergeMethod := opts.MergeMethod @@ -166,9 +172,9 @@ func mergeRun(opts *MergeOptions) error { if isTerminal { fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) } - } else { + } else if !opts.DeleteBranch { err := prompt.SurveyAskOne(&survey.Confirm{ - Message: fmt.Sprintf("Pull request #%d (%s) was already merged. Would you like to delete this local branch and switch to the default branch?", pr.Number, pr.Title), + Message: fmt.Sprintf("Pull request #%d (%s) was already merged. Delete the branch locally and switch to default branch?", pr.Number, pr.Title), Default: false, }, &deleteBranch) diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index ee57020f3..81fb63f51 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -518,6 +518,36 @@ func TestPrMerge_alreadyMerged(t *testing.T) { test.ExpectLines(t, output.Stderr(), "✔ Deleted branch blueberries and switched to branch master") } +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"} + } } }`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "blueberries", true, "pr merge 4 --merge") + if err == nil { + t.Fatalf("expected an error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) + + if !r.MatchString(err.Error()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + func TestPRMerge_interactive(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) From df31fae9c664e21e9d7910e4c6a277e6c862ff91 Mon Sep 17 00:00:00 2001 From: Devon Romanko <28825608+dpromanko@users.noreply.github.com> Date: Fri, 15 Jan 2021 17:18:04 -0500 Subject: [PATCH 119/129] remove prompt for deleting branches on pr merge in interactive mode when -d flag is passed --- pkg/cmd/pr/merge/merge.go | 11 ++++--- pkg/cmd/pr/merge/merge_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 83dd883c6..5c447b365 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -140,7 +140,7 @@ func mergeRun(opts *MergeOptions) error { mergeMethod := opts.MergeMethod if opts.InteractiveMode { - mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) + mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteBranch, opts.DeleteLocalBranch, crossRepoPR) if err != nil { if errors.Is(err, cancelError) { fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") @@ -238,7 +238,7 @@ func mergeRun(opts *MergeOptions) error { var cancelError = errors.New("cancelError") -func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { +func prInteractiveMerge(deleteBranch bool, deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { mergeMethodQuestion := &survey.Question{ Name: "mergeMethod", Prompt: &survey.Select{ @@ -250,7 +250,7 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque qs := []*survey.Question{mergeMethodQuestion} - if !crossRepoPR { + if !crossRepoPR && !deleteBranch { var message string if deleteLocalBranch { message = "Delete the branch locally and on GitHub?" @@ -300,6 +300,9 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque mergeMethod = api.PullRequestMergeMethodSquash } - deleteBranch := answers.DeleteBranch + if !deleteBranch { + deleteBranch = answers.DeleteBranch + } + return mergeMethod, deleteBranch, nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 81fb63f51..4f9e19ed3 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -606,6 +606,60 @@ func TestPRMerge_interactive(t *testing.T) { test.ExpectLines(t, output.Stderr(), "Merged pull request #3") } +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", + "number": 3 + }] } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git push origin --delete blueberries + cs.Stub("") // git branch -d + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "mergeMethod", + Value: 0, + }, + { + Name: "isConfirmed", + Value: true, + }, + }) + + output, err := runCommand(http, "blueberries", true, "-d") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master") +} + func TestPRMerge_interactiveCancelled(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) From 3afb1d0b1aeea95c594b376796aa5025f3b83335 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Sat, 16 Jan 2021 19:19:30 -0300 Subject: [PATCH 120/129] Use Testify assertions in test --- api/client_test.go | 19 ++-- api/pull_request_test.go | 12 ++- context/remote_test.go | 19 ++-- git/remote_test.go | 31 +++--- internal/config/config_file_test.go | 52 ++++------ pkg/cmd/issue/create/create_test.go | 46 ++++----- pkg/cmd/issue/list/list_test.go | 24 ++--- pkg/cmd/pr/checkout/checkout_test.go | 146 +++++++++++++-------------- pkg/cmd/pr/create/create_test.go | 66 ++++++------ pkg/cmd/pr/list/list_test.go | 17 +--- 10 files changed, 183 insertions(+), 249 deletions(-) diff --git a/api/client_test.go b/api/client_test.go index 4c272aa58..a28901f7e 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -5,19 +5,12 @@ import ( "errors" "io/ioutil" "net/http" - "reflect" "testing" "github.com/cli/cli/pkg/httpmock" + "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func TestGraphQL(t *testing.T) { http := &httpmock.Registry{} client := NewClient( @@ -38,13 +31,13 @@ func TestGraphQL(t *testing.T) { ) err := client.GraphQL("github.com", "QUERY", vars, &response) - eq(t, err, nil) - eq(t, response.Viewer.Login, "hubot") + assert.Equal(t, err, nil) + assert.Equal(t, response.Viewer.Login, "hubot") req := http.Requests[0] reqBody, _ := ioutil.ReadAll(req.Body) - eq(t, string(reqBody), `{"query":"QUERY","variables":{"name":"Mona"}}`) - eq(t, req.Header.Get("Authorization"), "token OTOKEN") + assert.Equal(t, string(reqBody), `{"query":"QUERY","variables":{"name":"Mona"}}`) + assert.Equal(t, req.Header.Get("Authorization"), "token OTOKEN") } func TestGraphQLError(t *testing.T) { @@ -84,7 +77,7 @@ func TestRESTGetDelete(t *testing.T) { r := bytes.NewReader([]byte(`{}`)) err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) - eq(t, err, nil) + assert.Equal(t, err, nil) } func TestRESTError(t *testing.T) { diff --git a/api/pull_request_test.go b/api/pull_request_test.go index 609a27e7c..444e160b3 100644 --- a/api/pull_request_test.go +++ b/api/pull_request_test.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "testing" + + "github.com/stretchr/testify/assert" ) func TestPullRequest_ChecksStatus(t *testing.T) { @@ -31,11 +33,11 @@ func TestPullRequest_ChecksStatus(t *testing.T) { } }] } } ` err := json.Unmarshal([]byte(payload), &pr) - eq(t, err, nil) + assert.Equal(t, err, nil) checks := pr.ChecksStatus() - eq(t, checks.Total, 8) - eq(t, checks.Pending, 3) - eq(t, checks.Failing, 3) - eq(t, checks.Passing, 2) + assert.Equal(t, checks.Total, 8) + assert.Equal(t, checks.Pending, 3) + assert.Equal(t, checks.Failing, 3) + assert.Equal(t, checks.Passing, 2) } diff --git a/context/remote_test.go b/context/remote_test.go index 1d2140c90..e8c11037c 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -3,20 +3,13 @@ package context import ( "errors" "net/url" - "reflect" "testing" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" + "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func Test_Remotes_FindByName(t *testing.T) { list := Remotes{ &Remote{Remote: &git.Remote{Name: "mona"}, Repo: ghrepo.New("monalisa", "myfork")}, @@ -25,15 +18,15 @@ func Test_Remotes_FindByName(t *testing.T) { } r, err := list.FindByName("upstream", "origin") - eq(t, err, nil) - eq(t, r.Name, "upstream") + assert.Equal(t, err, nil) + assert.Equal(t, r.Name, "upstream") r, err = list.FindByName("nonexistent", "*") - eq(t, err, nil) - eq(t, r.Name, "mona") + assert.Equal(t, err, nil) + assert.Equal(t, r.Name, "mona") _, err = list.FindByName("nonexistent") - eq(t, err, errors.New(`no GitHub remotes found`)) + assert.Equal(t, err, errors.New(`no GitHub remotes found`)) } func Test_translateRemotes(t *testing.T) { diff --git a/git/remote_test.go b/git/remote_test.go index e8c091653..0bc09e87b 100644 --- a/git/remote_test.go +++ b/git/remote_test.go @@ -1,17 +1,10 @@ package git import ( - "reflect" "testing" -) -// TODO: extract assertion helpers into a shared package -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} + "github.com/stretchr/testify/assert" +) func Test_parseRemotes(t *testing.T) { remoteList := []string{ @@ -23,20 +16,20 @@ func Test_parseRemotes(t *testing.T) { "zardoz\thttps://example.com/zed.git (push)", } r := parseRemotes(remoteList) - eq(t, len(r), 4) + assert.Equal(t, len(r), 4) - eq(t, r[0].Name, "mona") - eq(t, r[0].FetchURL.String(), "ssh://git@github.com/monalisa/myfork.git") + assert.Equal(t, r[0].Name, "mona") + assert.Equal(t, r[0].FetchURL.String(), "ssh://git@github.com/monalisa/myfork.git") if r[0].PushURL != nil { t.Errorf("expected no PushURL, got %q", r[0].PushURL) } - eq(t, r[1].Name, "origin") - eq(t, r[1].FetchURL.Path, "/monalisa/octo-cat.git") - eq(t, r[1].PushURL.Path, "/monalisa/octo-cat-push.git") + assert.Equal(t, r[1].Name, "origin") + assert.Equal(t, r[1].FetchURL.Path, "/monalisa/octo-cat.git") + assert.Equal(t, r[1].PushURL.Path, "/monalisa/octo-cat-push.git") - eq(t, r[2].Name, "upstream") - eq(t, r[2].FetchURL.Host, "example.com") - eq(t, r[2].PushURL.Host, "github.com") + assert.Equal(t, r[2].Name, "upstream") + assert.Equal(t, r[2].FetchURL.Host, "example.com") + assert.Equal(t, r[2].PushURL.Host, "github.com") - eq(t, r[3].Name, "zardoz") + assert.Equal(t, r[3].Name, "zardoz") } diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index cc6ba287f..ac758736b 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -3,20 +3,12 @@ package config import ( "bytes" "fmt" - "reflect" "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func Test_parseConfig(t *testing.T) { defer StubConfig(`--- hosts: @@ -25,13 +17,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.Equal(t, err, nil) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.Equal(t, err, nil) + assert.Equal(t, user, "monalisa") token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.Equal(t, err, nil) + assert.Equal(t, token, "OTOKEN") } func Test_parseConfig_multipleHosts(t *testing.T) { @@ -45,13 +37,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.Equal(t, err, nil) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.Equal(t, err, nil) + assert.Equal(t, user, "monalisa") token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.Equal(t, err, nil) + assert.Equal(t, token, "OTOKEN") } func Test_parseConfig_hostsFile(t *testing.T) { @@ -61,13 +53,13 @@ github.com: oauth_token: OTOKEN `)() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.Equal(t, err, nil) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.Equal(t, err, nil) + assert.Equal(t, user, "monalisa") token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.Equal(t, err, nil) + assert.Equal(t, token, "OTOKEN") } func Test_parseConfig_hostFallback(t *testing.T) { @@ -83,16 +75,16 @@ example.com: git_protocol: https `)() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.Equal(t, err, nil) val, err := config.Get("example.com", "git_protocol") - eq(t, err, nil) - eq(t, val, "https") + assert.Equal(t, err, nil) + assert.Equal(t, val, "https") val, err = config.Get("github.com", "git_protocol") - eq(t, err, nil) - eq(t, val, "ssh") + assert.Equal(t, err, nil) + assert.Equal(t, val, "ssh") val, err = config.Get("nonexistent.io", "git_protocol") - eq(t, err, nil) - eq(t, val, "ssh") + assert.Equal(t, err, nil) + assert.Equal(t, val, "ssh") } func Test_ParseConfig_migrateConfig(t *testing.T) { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 21177164a..1aa177684 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "os/exec" - "reflect" "strings" "testing" @@ -26,13 +25,6 @@ import ( "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { return runCommandWithRootDirOverridden(rt, isTTY, cli, "") } @@ -120,7 +112,7 @@ func TestIssueCreate(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } func TestIssueCreate_recover(t *testing.T) { @@ -152,9 +144,9 @@ func TestIssueCreate_recover(t *testing.T) { "URL": "https://github.com/OWNER/REPO/issues/12" } } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "recovered title") - eq(t, inputs["body"], "recovered body") - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + assert.Equal(t, inputs["title"], "recovered title") + assert.Equal(t, inputs["body"], "recovered body") + assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) })) as, teardown := prompt.InitAskStubber() @@ -201,7 +193,7 @@ func TestIssueCreate_recover(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } func TestIssueCreate_nonLegacyTemplate(t *testing.T) { @@ -259,7 +251,7 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } func TestIssueCreate_continueInBrowser(t *testing.T) { @@ -376,12 +368,12 @@ func TestIssueCreate_metadata(t *testing.T) { "URL": "https://github.com/OWNER/REPO/issues/12" } } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "TITLE") - eq(t, inputs["body"], "BODY") - eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - eq(t, inputs["milestoneId"], "BIGONEID") + assert.Equal(t, inputs["title"], "TITLE") + assert.Equal(t, inputs["body"], "BODY") + assert.Equal(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + assert.Equal(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + assert.Equal(t, inputs["milestoneId"], "BIGONEID") if v, ok := inputs["userIds"]; ok { t.Errorf("did not expect userIds: %v", v) } @@ -395,7 +387,7 @@ func TestIssueCreate_metadata(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } func TestIssueCreate_disabledIssues(t *testing.T) { @@ -437,9 +429,9 @@ func TestIssueCreate_web(t *testing.T) { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/new") - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") + assert.Equal(t, url, "https://github.com/OWNER/REPO/issues/new") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") } func TestIssueCreate_webTitleBody(t *testing.T) { @@ -462,7 +454,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) { t.Fatal("expected a command to run") } url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") - eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") + assert.Equal(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") } diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 456741a91..8856b800d 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "regexp" "testing" @@ -22,13 +21,6 @@ import ( "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -79,7 +71,7 @@ func TestIssueList_nontty(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, output.Stderr(), "") test.ExpectLines(t, output.String(), `1[\t]+number won[\t]+label[\t]+\d+`, `2[\t]+number too[\t]+label[\t]+\d+`, @@ -147,8 +139,8 @@ func TestIssueList_tty_withFlags(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), "") - eq(t, output.String(), ` + assert.Equal(t, output.Stderr(), "") + assert.Equal(t, output.String(), ` No issues match your search in OWNER/REPO `) @@ -191,8 +183,8 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) { _, assigneeDeclared := reqBody.Variables["assignee"] _, labelsDeclared := reqBody.Variables["labels"] - eq(t, assigneeDeclared, false) - eq(t, labelsDeclared, false) + assert.Equal(t, assigneeDeclared, false) + assert.Equal(t, labelsDeclared, false) } func TestIssueList_disabledIssues(t *testing.T) { @@ -231,14 +223,14 @@ func TestIssueList_web(t *testing.T) { expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) + assert.Equal(t, url, expectedURL) } func TestIssueList_milestoneNotFound(t *testing.T) { diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 2ca060587..c37519259 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -7,7 +7,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" @@ -25,13 +24,6 @@ import ( "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - type errorStub struct { message string } @@ -137,10 +129,10 @@ func TestPRCheckout_sameRepo(t *testing.T) { if !assert.Equal(t, 4, len(ranCommands)) { return } - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") } func TestPRCheckout_urlArg(t *testing.T) { @@ -174,11 +166,11 @@ func TestPRCheckout_urlArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") + assert.Equal(t, len(ranCommands), 4) + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") } func TestPRCheckout_urlArg_differentBase(t *testing.T) { @@ -213,8 +205,8 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) reqBody := struct { @@ -225,12 +217,12 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Owner, "OTHER") - eq(t, reqBody.Variables.Repo, "POE") + assert.Equal(t, reqBody.Variables.Owner, "OTHER") + assert.Equal(t, reqBody.Variables.Repo, "POE") - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git") + assert.Equal(t, len(ranCommands), 5) + assert.Equal(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git") } func TestPRCheckout_branchArg(t *testing.T) { @@ -265,11 +257,11 @@ func TestPRCheckout_branchArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `hubot:feature`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, len(ranCommands), 5) + assert.Equal(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature") } func TestPRCheckout_existingBranch(t *testing.T) { @@ -304,13 +296,13 @@ func TestPRCheckout_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 3) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") + assert.Equal(t, len(ranCommands), 3) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") } func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { @@ -356,14 +348,14 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer restoreCmd() output, err := runCommand(http, remotes, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, len(ranCommands), 4) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") } func TestPRCheckout_differentRepo(t *testing.T) { @@ -398,14 +390,14 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head") + assert.Equal(t, len(ranCommands), 4) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head") } func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { @@ -440,12 +432,12 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, len(ranCommands), 2) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") } func TestPRCheckout_detachedHead(t *testing.T) { @@ -480,12 +472,12 @@ func TestPRCheckout_detachedHead(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, len(ranCommands), 2) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") } func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { @@ -520,12 +512,12 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "feature", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head") - eq(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD") + assert.Equal(t, len(ranCommands), 2) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD") } func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { @@ -592,14 +584,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, len(ranCommands), 4) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") } func TestPRCheckout_recurseSubmodules(t *testing.T) { @@ -633,13 +625,13 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123 --recurse-submodules`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.Equal(t, err, nil) + assert.Equal(t, output.String(), "") - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[3], " "), "git submodule sync --recursive") - eq(t, strings.Join(ranCommands[4], " "), "git submodule update --init --recursive") + assert.Equal(t, len(ranCommands), 5) + assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") + assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") + assert.Equal(t, strings.Join(ranCommands[3], " "), "git submodule sync --recursive") + assert.Equal(t, strings.Join(ranCommands[4], " "), "git submodule update --init --recursive") } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 63963aa77..36a9b6dd8 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -7,7 +7,6 @@ import ( "io/ioutil" "net/http" "os" - "reflect" "strings" "testing" @@ -28,13 +27,6 @@ import ( "github.com/stretchr/testify/require" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) { return runCommandWithRootDirOverridden(rt, remotes, branch, isTTY, cli, "") } @@ -115,12 +107,12 @@ func TestPRCreate_nontty_web(t *testing.T) { output, err := runCommand(http, nil, "feature", false, `--web --head=feature`) require.NoError(t, err) - eq(t, output.String(), "") - eq(t, output.Stderr(), "") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "") - eq(t, len(cs.Calls), 3) + assert.Equal(t, len(cs.Calls), 3) browserCall := cs.Calls[2].Args - eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") } @@ -165,7 +157,7 @@ func TestPRCreate_recover(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["userIds"], []interface{}{"JILLID"}) + assert.Equal(t, inputs["userIds"], []interface{}{"JILLID"}) })) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), @@ -225,7 +217,7 @@ func TestPRCreate_recover(t *testing.T) { output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "") assert.NoError(t, err) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_nontty(t *testing.T) { @@ -532,7 +524,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates") require.NoError(t, err) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_metadata(t *testing.T) { @@ -600,8 +592,8 @@ func TestPRCreate_metadata(t *testing.T) { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "TITLE") - eq(t, inputs["body"], "BODY") + assert.Equal(t, inputs["title"], "TITLE") + assert.Equal(t, inputs["body"], "BODY") if v, ok := inputs["assigneeIds"]; ok { t.Errorf("did not expect assigneeIds: %v", v) } @@ -616,11 +608,11 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["pullRequestId"], "NEWPULLID") - eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - eq(t, inputs["milestoneId"], "BIGONEID") + assert.Equal(t, inputs["pullRequestId"], "NEWPULLID") + assert.Equal(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + assert.Equal(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + assert.Equal(t, inputs["milestoneId"], "BIGONEID") })) http.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), @@ -629,10 +621,10 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["pullRequestId"], "NEWPULLID") - eq(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"}) - eq(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"}) - eq(t, inputs["union"], true) + assert.Equal(t, inputs["pullRequestId"], "NEWPULLID") + assert.Equal(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"}) + assert.Equal(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"}) + assert.Equal(t, inputs["union"], true) })) cs, cmdTeardown := test.InitCmdStubber() @@ -642,9 +634,9 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) - eq(t, err, nil) + assert.Equal(t, err, nil) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_alreadyExists(t *testing.T) { @@ -705,13 +697,13 @@ func TestPRCreate_web(t *testing.T) { output, err := runCommand(http, nil, "feature", true, `--web`) require.NoError(t, err) - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n") - eq(t, len(cs.Calls), 6) - eq(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature") + assert.Equal(t, len(cs.Calls), 6) + assert.Equal(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature") browserCall := cs.Calls[5].Args - eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") } func Test_determineTrackingBranch_empty(t *testing.T) { @@ -779,10 +771,10 @@ deadbeef refs/remotes/upstream/feature`) // git show-ref --verify (ShowRefs) t.Fatal("expected result, got nil") } - eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}) + assert.Equal(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}) - eq(t, ref.RemoteName, "upstream") - eq(t, ref.BranchName, "feature") + assert.Equal(t, ref.RemoteName, "upstream") + assert.Equal(t, ref.BranchName, "feature") } func Test_determineTrackingBranch_respectTrackingConfig(t *testing.T) { @@ -806,7 +798,7 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs) t.Errorf("expected nil result, got %v", ref) } - eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}) + assert.Equal(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}) } func Test_generateCompareURL(t *testing.T) { diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index ab99e4aa9..24cfd8f54 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" @@ -20,12 +19,6 @@ import ( "github.com/stretchr/testify/assert" ) -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -122,8 +115,8 @@ func TestPRList_filtering(t *testing.T) { t.Fatal(err) } - eq(t, output.Stderr(), "") - eq(t, output.String(), ` + assert.Equal(t, output.Stderr(), "") + assert.Equal(t, output.String(), ` No pull requests match your search in OWNER/REPO `) @@ -220,12 +213,12 @@ func TestPRList_web(t *testing.T) { expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk" - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") + assert.Equal(t, output.String(), "") + assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) + assert.Equal(t, url, expectedURL) } From 66546e2245108f117f262b49b172eba1d63328e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 18 Jan 2021 18:10:20 +0100 Subject: [PATCH 121/129] When `pr merge --delete-branch` flag is supplied, avoid prompting for it --- pkg/cmd/pr/merge/merge.go | 151 +++++++++++++++------------------ pkg/cmd/pr/merge/merge_test.go | 58 +++++++------ 2 files changed, 102 insertions(+), 107 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 5c447b365..4cac24242 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -27,11 +27,13 @@ type MergeOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) - SelectorArg string - DeleteBranch bool - DeleteLocalBranch bool - MergeMethod api.PullRequestMergeMethod - InteractiveMode bool + SelectorArg string + DeleteBranch bool + MergeMethod api.PullRequestMergeMethod + + IsDeleteBranchIndicated bool + CanDeleteLocalBranch bool + InteractiveMode bool } func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command { @@ -90,7 +92,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")} } - opts.DeleteLocalBranch = !cmd.Flags().Changed("repo") + opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch") + opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo") if runF != nil { return runF(opts) @@ -125,22 +128,16 @@ func mergeRun(opts *MergeOptions) error { return err } - isPRAlreadyMerged := pr.State == "MERGED" - - if isPRAlreadyMerged && !opts.InteractiveMode && !opts.DeleteBranch { - err := fmt.Errorf("%s Pull request #%d (%s) was already merged", cs.Red("!"), pr.Number, pr.Title) - return err - } - deleteBranch := opts.DeleteBranch crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() isTerminal := opts.IO.IsStdoutTTY() + isPRAlreadyMerged := pr.State == "MERGED" if !isPRAlreadyMerged { mergeMethod := opts.MergeMethod if opts.InteractiveMode { - mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteBranch, opts.DeleteLocalBranch, crossRepoPR) + mergeMethod, deleteBranch, err = prInteractiveMerge(opts, crossRepoPR) if err != nil { if errors.Is(err, cancelError) { fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") @@ -150,87 +147,81 @@ func mergeRun(opts *MergeOptions) error { } } - var action string - if mergeMethod == api.PullRequestMergeMethodRebase { - action = "Rebased and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) - } else if mergeMethod == api.PullRequestMergeMethodSquash { - action = "Squashed and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) - } else if mergeMethod == api.PullRequestMergeMethodMerge { - action = "Merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) - } else { - err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) + err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod) + if err != nil { return err } - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - if isTerminal { + action := "Merged" + switch mergeMethod { + case api.PullRequestMergeMethodRebase: + action = "Rebased and merged" + case api.PullRequestMergeMethodSquash: + action = "Squashed and merged" + } fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) } - } else if !opts.DeleteBranch { + } else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode { err := prompt.SurveyAskOne(&survey.Confirm{ - Message: fmt.Sprintf("Pull request #%d (%s) was already merged. Delete the branch locally and switch to default branch?", pr.Number, pr.Title), + Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number), Default: false, }, &deleteBranch) - if err != nil { return fmt.Errorf("could not prompt: %w", err) } } - if deleteBranch { - branchSwitchString := "" + if !deleteBranch { + return nil + } - if opts.DeleteLocalBranch && !crossRepoPR { - currentBranch, err := opts.Branch() + branchSwitchString := "" + + if opts.CanDeleteLocalBranch && !crossRepoPR { + currentBranch, err := opts.Branch() + if err != nil { + return err + } + + var branchToSwitchTo string + if currentBranch == pr.HeadRefName { + branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) if err != nil { return err } - - var branchToSwitchTo string - if currentBranch == pr.HeadRefName { - branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - err = git.CheckoutBranch(branchToSwitchTo) - if err != nil { - return err - } - } - - 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 branchToSwitchTo != "" { - branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo)) - } - } - - if !isPRAlreadyMerged && !crossRepoPR { - err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) - var httpErr api.HTTPError - // The ref might have already been deleted by GitHub - if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { - err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) + err = git.CheckoutBranch(branchToSwitchTo) + if err != nil { return err } } - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString) + 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 branchToSwitchTo != "" { + branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo)) + } + } + + if !isPRAlreadyMerged && !crossRepoPR { + err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) + var httpErr api.HTTPError + // The ref might have already been deleted by GitHub + if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { + err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) + return err + } + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString) } return nil @@ -238,7 +229,7 @@ func mergeRun(opts *MergeOptions) error { var cancelError = errors.New("cancelError") -func prInteractiveMerge(deleteBranch bool, deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { +func prInteractiveMerge(opts *MergeOptions, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { mergeMethodQuestion := &survey.Question{ Name: "mergeMethod", Prompt: &survey.Select{ @@ -250,9 +241,9 @@ func prInteractiveMerge(deleteBranch bool, deleteLocalBranch bool, crossRepoPR b qs := []*survey.Question{mergeMethodQuestion} - if !crossRepoPR && !deleteBranch { + if !crossRepoPR && !opts.IsDeleteBranchIndicated { var message string - if deleteLocalBranch { + if opts.CanDeleteLocalBranch { message = "Delete the branch locally and on GitHub?" } else { message = "Delete the branch on GitHub?" @@ -280,7 +271,9 @@ func prInteractiveMerge(deleteBranch bool, deleteLocalBranch bool, crossRepoPR b MergeMethod int DeleteBranch bool IsConfirmed bool - }{} + }{ + DeleteBranch: opts.DeleteBranch, + } err := prompt.SurveyAsk(qs, &answers) if err != nil { @@ -300,9 +293,5 @@ func prInteractiveMerge(deleteBranch bool, deleteLocalBranch bool, crossRepoPR b mergeMethod = api.PullRequestMergeMethodSquash } - if !deleteBranch { - deleteBranch = answers.DeleteBranch - } - - return mergeMethod, deleteBranch, nil + return mergeMethod, answers.DeleteBranch, nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 4f9e19ed3..963fe13fe 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -14,6 +14,7 @@ import ( "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/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -37,11 +38,25 @@ func Test_NewCmdMerge(t *testing.T) { args: "123", isTTY: true, want: MergeOptions{ - SelectorArg: "123", - DeleteBranch: false, - DeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, - InteractiveMode: true, + SelectorArg: "123", + DeleteBranch: false, + IsDeleteBranchIndicated: false, + CanDeleteLocalBranch: true, + MergeMethod: api.PullRequestMergeMethodMerge, + InteractiveMode: true, + }, + }, + { + name: "delete-branch specified", + args: "--delete-branch=false", + isTTY: true, + want: MergeOptions{ + SelectorArg: "", + DeleteBranch: false, + IsDeleteBranchIndicated: true, + CanDeleteLocalBranch: true, + MergeMethod: api.PullRequestMergeMethodMerge, + InteractiveMode: true, }, }, { @@ -105,7 +120,7 @@ func Test_NewCmdMerge(t *testing.T) { assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) assert.Equal(t, tt.want.DeleteBranch, opts.DeleteBranch) - assert.Equal(t, tt.want.DeleteLocalBranch, opts.DeleteLocalBranch) + assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch) assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod) assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode) }) @@ -498,13 +513,12 @@ func TestPrMerge_alreadyMerged(t *testing.T) { } } } }`)) - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d + cs.Register(`git checkout master`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") as, surveyTeardown := prompt.InitAskStubber() defer surveyTeardown() @@ -528,24 +542,16 @@ func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) { "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} } } }`)) - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) output, err := runCommand(http, "blueberries", true, "pr merge 4 --merge") - if err == nil { - t.Fatalf("expected an error running command `pr merge`: %v", err) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) } - r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRMerge_interactive(t *testing.T) { From 45f4a1f087984b4d048cf6f57183e21d17096506 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Mon, 18 Jan 2021 21:00:59 -0300 Subject: [PATCH 122/129] Equal: flip arguments position --- api/client_test.go | 10 +- api/pull_request_test.go | 10 +- context/remote_test.go | 10 +- git/remote_test.go | 20 ++-- internal/config/config_file_test.go | 44 ++++----- pkg/cmd/issue/create/create_test.go | 38 ++++---- pkg/cmd/issue/list/list_test.go | 18 ++-- pkg/cmd/pr/checkout/checkout_test.go | 138 +++++++++++++-------------- pkg/cmd/pr/create/create_test.go | 58 +++++------ pkg/cmd/pr/list/list_test.go | 10 +- 10 files changed, 178 insertions(+), 178 deletions(-) diff --git a/api/client_test.go b/api/client_test.go index a28901f7e..ba71c6a46 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -31,13 +31,13 @@ func TestGraphQL(t *testing.T) { ) err := client.GraphQL("github.com", "QUERY", vars, &response) - assert.Equal(t, err, nil) - assert.Equal(t, response.Viewer.Login, "hubot") + assert.Equal(t, nil, err) + assert.Equal(t, "hubot", response.Viewer.Login) req := http.Requests[0] reqBody, _ := ioutil.ReadAll(req.Body) - assert.Equal(t, string(reqBody), `{"query":"QUERY","variables":{"name":"Mona"}}`) - assert.Equal(t, req.Header.Get("Authorization"), "token OTOKEN") + assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody)) + assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization")) } func TestGraphQLError(t *testing.T) { @@ -77,7 +77,7 @@ func TestRESTGetDelete(t *testing.T) { r := bytes.NewReader([]byte(`{}`)) err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) } func TestRESTError(t *testing.T) { diff --git a/api/pull_request_test.go b/api/pull_request_test.go index 444e160b3..3d38e2ed2 100644 --- a/api/pull_request_test.go +++ b/api/pull_request_test.go @@ -33,11 +33,11 @@ func TestPullRequest_ChecksStatus(t *testing.T) { } }] } } ` err := json.Unmarshal([]byte(payload), &pr) - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) checks := pr.ChecksStatus() - assert.Equal(t, checks.Total, 8) - assert.Equal(t, checks.Pending, 3) - assert.Equal(t, checks.Failing, 3) - assert.Equal(t, checks.Passing, 2) + assert.Equal(t, 8, checks.Total) + assert.Equal(t, 3, checks.Pending) + assert.Equal(t, 3, checks.Failing) + assert.Equal(t, 2, checks.Passing) } diff --git a/context/remote_test.go b/context/remote_test.go index e8c11037c..d6ebe3c49 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -18,15 +18,15 @@ func Test_Remotes_FindByName(t *testing.T) { } r, err := list.FindByName("upstream", "origin") - assert.Equal(t, err, nil) - assert.Equal(t, r.Name, "upstream") + assert.Equal(t, nil, err) + assert.Equal(t, "upstream", r.Name) r, err = list.FindByName("nonexistent", "*") - assert.Equal(t, err, nil) - assert.Equal(t, r.Name, "mona") + assert.Equal(t, nil, err) + assert.Equal(t, "mona", r.Name) _, err = list.FindByName("nonexistent") - assert.Equal(t, err, errors.New(`no GitHub remotes found`)) + assert.Equal(t, errors.New(`no GitHub remotes found`), err) } func Test_translateRemotes(t *testing.T) { diff --git a/git/remote_test.go b/git/remote_test.go index 0bc09e87b..382896590 100644 --- a/git/remote_test.go +++ b/git/remote_test.go @@ -16,20 +16,20 @@ func Test_parseRemotes(t *testing.T) { "zardoz\thttps://example.com/zed.git (push)", } r := parseRemotes(remoteList) - assert.Equal(t, len(r), 4) + assert.Equal(t, 4, len(r)) - assert.Equal(t, r[0].Name, "mona") - assert.Equal(t, r[0].FetchURL.String(), "ssh://git@github.com/monalisa/myfork.git") + assert.Equal(t, "mona", r[0].Name) + assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) if r[0].PushURL != nil { t.Errorf("expected no PushURL, got %q", r[0].PushURL) } - assert.Equal(t, r[1].Name, "origin") - assert.Equal(t, r[1].FetchURL.Path, "/monalisa/octo-cat.git") - assert.Equal(t, r[1].PushURL.Path, "/monalisa/octo-cat-push.git") + assert.Equal(t, "origin", r[1].Name) + assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) + assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) - assert.Equal(t, r[2].Name, "upstream") - assert.Equal(t, r[2].FetchURL.Host, "example.com") - assert.Equal(t, r[2].PushURL.Host, "github.com") + assert.Equal(t, "upstream", r[2].Name) + assert.Equal(t, "example.com", r[2].FetchURL.Host) + assert.Equal(t, "github.com", r[2].PushURL.Host) - assert.Equal(t, r[3].Name, "zardoz") + assert.Equal(t, "zardoz", r[3].Name) } diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index ac758736b..983140780 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -17,13 +17,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) user, err := config.Get("github.com", "user") - assert.Equal(t, err, nil) - assert.Equal(t, user, "monalisa") + assert.Equal(t, nil, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, err, nil) - assert.Equal(t, token, "OTOKEN") + assert.Equal(t, nil, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_multipleHosts(t *testing.T) { @@ -37,13 +37,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) user, err := config.Get("github.com", "user") - assert.Equal(t, err, nil) - assert.Equal(t, user, "monalisa") + assert.Equal(t, nil, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, err, nil) - assert.Equal(t, token, "OTOKEN") + assert.Equal(t, nil, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_hostsFile(t *testing.T) { @@ -53,13 +53,13 @@ github.com: oauth_token: OTOKEN `)() config, err := ParseConfig("config.yml") - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) user, err := config.Get("github.com", "user") - assert.Equal(t, err, nil) - assert.Equal(t, user, "monalisa") + assert.Equal(t, nil, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, err, nil) - assert.Equal(t, token, "OTOKEN") + assert.Equal(t, nil, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_hostFallback(t *testing.T) { @@ -75,16 +75,16 @@ example.com: git_protocol: https `)() config, err := ParseConfig("config.yml") - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) val, err := config.Get("example.com", "git_protocol") - assert.Equal(t, err, nil) - assert.Equal(t, val, "https") + assert.Equal(t, nil, err) + assert.Equal(t, "https", val) val, err = config.Get("github.com", "git_protocol") - assert.Equal(t, err, nil) - assert.Equal(t, val, "ssh") + assert.Equal(t, nil, err) + assert.Equal(t, "ssh", val) val, err = config.Get("nonexistent.io", "git_protocol") - assert.Equal(t, err, nil) - assert.Equal(t, val, "ssh") + assert.Equal(t, nil, err) + assert.Equal(t, "ssh", val) } func Test_ParseConfig_migrateConfig(t *testing.T) { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 1aa177684..9910bc7cd 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -112,7 +112,7 @@ func TestIssueCreate(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_recover(t *testing.T) { @@ -144,9 +144,9 @@ func TestIssueCreate_recover(t *testing.T) { "URL": "https://github.com/OWNER/REPO/issues/12" } } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["title"], "recovered title") - assert.Equal(t, inputs["body"], "recovered body") - assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + assert.Equal(t, "recovered title", inputs["title"]) + assert.Equal(t, "recovered body", inputs["body"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) })) as, teardown := prompt.InitAskStubber() @@ -193,7 +193,7 @@ func TestIssueCreate_recover(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_nonLegacyTemplate(t *testing.T) { @@ -251,7 +251,7 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_continueInBrowser(t *testing.T) { @@ -368,12 +368,12 @@ func TestIssueCreate_metadata(t *testing.T) { "URL": "https://github.com/OWNER/REPO/issues/12" } } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["title"], "TITLE") - assert.Equal(t, inputs["body"], "BODY") - assert.Equal(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - assert.Equal(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - assert.Equal(t, inputs["milestoneId"], "BIGONEID") + assert.Equal(t, "TITLE", inputs["title"]) + assert.Equal(t, "BODY", inputs["body"]) + assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) + assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) + assert.Equal(t, "BIGONEID", inputs["milestoneId"]) if v, ok := inputs["userIds"]; ok { t.Errorf("did not expect userIds: %v", v) } @@ -387,7 +387,7 @@ func TestIssueCreate_metadata(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_disabledIssues(t *testing.T) { @@ -429,9 +429,9 @@ func TestIssueCreate_web(t *testing.T) { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - assert.Equal(t, url, "https://github.com/OWNER/REPO/issues/new") - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new", url) + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr()) } func TestIssueCreate_webTitleBody(t *testing.T) { @@ -454,7 +454,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) { t.Fatal("expected a command to run") } url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") - assert.Equal(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle", url) + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr()) } diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 8856b800d..a8fb93264 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -71,7 +71,7 @@ func TestIssueList_nontty(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - assert.Equal(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), `1[\t]+number won[\t]+label[\t]+\d+`, `2[\t]+number too[\t]+label[\t]+\d+`, @@ -139,11 +139,11 @@ func TestIssueList_tty_withFlags(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - assert.Equal(t, output.Stderr(), "") - assert.Equal(t, output.String(), ` + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, ` No issues match your search in OWNER/REPO -`) +`, output.String()) } func TestIssueList_withInvalidLimitFlag(t *testing.T) { @@ -183,8 +183,8 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) { _, assigneeDeclared := reqBody.Variables["assignee"] _, labelsDeclared := reqBody.Variables["labels"] - assert.Equal(t, assigneeDeclared, false) - assert.Equal(t, labelsDeclared, false) + assert.Equal(t, false, assigneeDeclared) + assert.Equal(t, false, labelsDeclared) } func TestIssueList_disabledIssues(t *testing.T) { @@ -223,14 +223,14 @@ func TestIssueList_web(t *testing.T) { expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - assert.Equal(t, url, expectedURL) + assert.Equal(t, expectedURL, url) } func TestIssueList_milestoneNotFound(t *testing.T) { diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index c37519259..bc9f087aa 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -129,10 +129,10 @@ func TestPRCheckout_sameRepo(t *testing.T) { if !assert.Equal(t, 4, len(ranCommands)) { return } - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_urlArg(t *testing.T) { @@ -166,11 +166,11 @@ func TestPRCheckout_urlArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 4) - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_urlArg_differentBase(t *testing.T) { @@ -205,8 +205,8 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) reqBody := struct { @@ -217,12 +217,12 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - assert.Equal(t, reqBody.Variables.Owner, "OTHER") - assert.Equal(t, reqBody.Variables.Repo, "POE") + assert.Equal(t, "OTHER", reqBody.Variables.Owner) + assert.Equal(t, "POE", reqBody.Variables.Repo) - assert.Equal(t, len(ranCommands), 5) - assert.Equal(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote https://github.com/OTHER/POE.git", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_branchArg(t *testing.T) { @@ -257,11 +257,11 @@ func TestPRCheckout_branchArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `hubot:feature`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 5) - assert.Equal(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_existingBranch(t *testing.T) { @@ -296,13 +296,13 @@ func TestPRCheckout_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 3) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") + assert.Equal(t, 3, len(ranCommands)) + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " ")) } func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { @@ -348,14 +348,14 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer restoreCmd() output, err := runCommand(http, remotes, "master", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 4) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout -b feature --no-track robot-fork/feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote robot-fork", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_differentRepo(t *testing.T) { @@ -390,14 +390,14 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 4) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/pull/123/head", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { @@ -432,12 +432,12 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 2) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_detachedHead(t *testing.T) { @@ -472,12 +472,12 @@ func TestPRCheckout_detachedHead(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 2) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { @@ -512,12 +512,12 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "feature", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 2) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git merge --ff-only FETCH_HEAD", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { @@ -584,14 +584,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 4) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote https://github.com/hubot/REPO.git", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_recurseSubmodules(t *testing.T) { @@ -625,13 +625,13 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123 --recurse-submodules`) - assert.Equal(t, err, nil) - assert.Equal(t, output.String(), "") + assert.Equal(t, nil, err) + assert.Equal(t, "", output.String()) - assert.Equal(t, len(ranCommands), 5) - assert.Equal(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - assert.Equal(t, strings.Join(ranCommands[1], " "), "git checkout feature") - assert.Equal(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") - assert.Equal(t, strings.Join(ranCommands[3], " "), "git submodule sync --recursive") - assert.Equal(t, strings.Join(ranCommands[4], " "), "git submodule update --init --recursive") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git submodule sync --recursive", strings.Join(ranCommands[3], " ")) + assert.Equal(t, "git submodule update --init --recursive", strings.Join(ranCommands[4], " ")) } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 36a9b6dd8..c5134d102 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -107,12 +107,12 @@ func TestPRCreate_nontty_web(t *testing.T) { output, err := runCommand(http, nil, "feature", false, `--web --head=feature`) require.NoError(t, err) - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "") + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) - assert.Equal(t, len(cs.Calls), 3) + assert.Equal(t, 3, len(cs.Calls)) browserCall := cs.Calls[2].Args - assert.Equal(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1]) } @@ -157,7 +157,7 @@ func TestPRCreate_recover(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["userIds"], []interface{}{"JILLID"}) + assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"]) })) http.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), @@ -217,7 +217,7 @@ func TestPRCreate_recover(t *testing.T) { output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "") assert.NoError(t, err) - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } func TestPRCreate_nontty(t *testing.T) { @@ -524,7 +524,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates") require.NoError(t, err) - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } func TestPRCreate_metadata(t *testing.T) { @@ -592,8 +592,8 @@ func TestPRCreate_metadata(t *testing.T) { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["title"], "TITLE") - assert.Equal(t, inputs["body"], "BODY") + assert.Equal(t, "TITLE", inputs["title"]) + assert.Equal(t, "BODY", inputs["body"]) if v, ok := inputs["assigneeIds"]; ok { t.Errorf("did not expect assigneeIds: %v", v) } @@ -608,11 +608,11 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "NEWPULLID") - assert.Equal(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - assert.Equal(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - assert.Equal(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - assert.Equal(t, inputs["milestoneId"], "BIGONEID") + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) + assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) + assert.Equal(t, "BIGONEID", inputs["milestoneId"]) })) http.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), @@ -621,10 +621,10 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - assert.Equal(t, inputs["pullRequestId"], "NEWPULLID") - assert.Equal(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"}) - assert.Equal(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"}) - assert.Equal(t, inputs["union"], true) + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) + assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) + assert.Equal(t, true, inputs["union"]) })) cs, cmdTeardown := test.InitCmdStubber() @@ -634,9 +634,9 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) - assert.Equal(t, err, nil) + assert.Equal(t, nil, err) - assert.Equal(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } func TestPRCreate_alreadyExists(t *testing.T) { @@ -697,13 +697,13 @@ func TestPRCreate_web(t *testing.T) { output, err := runCommand(http, nil, "feature", true, `--web`) require.NoError(t, err) - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr()) - assert.Equal(t, len(cs.Calls), 6) - assert.Equal(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature") + assert.Equal(t, 6, len(cs.Calls)) + assert.Equal(t, "git push --set-upstream origin HEAD:feature", strings.Join(cs.Calls[4].Args, " ")) browserCall := cs.Calls[5].Args - assert.Equal(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1]) } func Test_determineTrackingBranch_empty(t *testing.T) { @@ -771,10 +771,10 @@ deadbeef refs/remotes/upstream/feature`) // git show-ref --verify (ShowRefs) t.Fatal("expected result, got nil") } - assert.Equal(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}) + assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}, cs.Calls[1].Args) - assert.Equal(t, ref.RemoteName, "upstream") - assert.Equal(t, ref.BranchName, "feature") + assert.Equal(t, "upstream", ref.RemoteName) + assert.Equal(t, "feature", ref.BranchName) } func Test_determineTrackingBranch_respectTrackingConfig(t *testing.T) { @@ -798,7 +798,7 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs) t.Errorf("expected nil result, got %v", ref) } - assert.Equal(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}) + assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}, cs.Calls[1].Args) } func Test_generateCompareURL(t *testing.T) { diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 24cfd8f54..d1398a0d6 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -115,11 +115,11 @@ func TestPRList_filtering(t *testing.T) { t.Fatal(err) } - assert.Equal(t, output.Stderr(), "") - assert.Equal(t, output.String(), ` + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, ` No pull requests match your search in OWNER/REPO -`) +`, output.String()) } func TestPRList_filteringRemoveDuplicate(t *testing.T) { @@ -213,8 +213,8 @@ func TestPRList_web(t *testing.T) { expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk" - assert.Equal(t, output.String(), "") - assert.Equal(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/pulls in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") From 75ebb863e3b2cfbaf1c6611a1af014d7ece7be7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 19 Jan 2021 13:59:37 +0100 Subject: [PATCH 123/129] Use testify assertions for error matching --- api/client_test.go | 4 ++-- api/pull_request_test.go | 2 +- context/remote_test.go | 7 +++--- internal/config/config_file_test.go | 28 +++++++++++----------- internal/config/config_type_test.go | 12 +++++----- internal/config/from_env_test.go | 4 +++- internal/ghinstance/host_test.go | 2 +- pkg/cmd/alias/delete/delete_test.go | 4 +--- pkg/cmd/alias/set/set_test.go | 9 ++----- pkg/cmd/auth/login/login_test.go | 18 ++++++-------- pkg/cmd/auth/logout/logout_test.go | 35 +++++++++++----------------- pkg/cmd/auth/refresh/refresh_test.go | 19 +++++++-------- pkg/cmd/auth/status/status_test.go | 19 +++++++-------- pkg/cmd/issue/create/create_test.go | 6 +---- pkg/cmd/pr/checkout/checkout_test.go | 26 ++++++++++----------- pkg/cmd/pr/checks/checks_test.go | 8 +++---- pkg/cmd/pr/create/create_test.go | 8 ++----- pkg/cmd/pr/diff/diff_test.go | 10 ++------ pkg/cmd/pr/review/review_test.go | 5 +--- pkg/cmd/repo/clone/clone_test.go | 8 +++---- 20 files changed, 95 insertions(+), 139 deletions(-) diff --git a/api/client_test.go b/api/client_test.go index ba71c6a46..8edf279ea 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -31,7 +31,7 @@ func TestGraphQL(t *testing.T) { ) err := client.GraphQL("github.com", "QUERY", vars, &response) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "hubot", response.Viewer.Login) req := http.Requests[0] @@ -77,7 +77,7 @@ func TestRESTGetDelete(t *testing.T) { r := bytes.NewReader([]byte(`{}`)) err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) - assert.Equal(t, nil, err) + assert.NoError(t, err) } func TestRESTError(t *testing.T) { diff --git a/api/pull_request_test.go b/api/pull_request_test.go index 3d38e2ed2..9fb1d9e72 100644 --- a/api/pull_request_test.go +++ b/api/pull_request_test.go @@ -33,7 +33,7 @@ func TestPullRequest_ChecksStatus(t *testing.T) { } }] } } ` err := json.Unmarshal([]byte(payload), &pr) - assert.Equal(t, nil, err) + assert.NoError(t, err) checks := pr.ChecksStatus() assert.Equal(t, 8, checks.Total) diff --git a/context/remote_test.go b/context/remote_test.go index d6ebe3c49..de9f21901 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -1,7 +1,6 @@ package context import ( - "errors" "net/url" "testing" @@ -18,15 +17,15 @@ func Test_Remotes_FindByName(t *testing.T) { } r, err := list.FindByName("upstream", "origin") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "upstream", r.Name) r, err = list.FindByName("nonexistent", "*") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "mona", r.Name) _, err = list.FindByName("nonexistent") - assert.Equal(t, errors.New(`no GitHub remotes found`), err) + assert.Error(t, err, "no GitHub remotes found") } func Test_translateRemotes(t *testing.T) { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 983140780..35ecabda4 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -17,12 +17,12 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - assert.Equal(t, nil, err) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "OTOKEN", token) } @@ -37,12 +37,12 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - assert.Equal(t, nil, err) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "OTOKEN", token) } @@ -53,12 +53,12 @@ github.com: oauth_token: OTOKEN `)() config, err := ParseConfig("config.yml") - assert.Equal(t, nil, err) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "OTOKEN", token) } @@ -75,15 +75,15 @@ example.com: git_protocol: https `)() config, err := ParseConfig("config.yml") - assert.Equal(t, nil, err) + assert.NoError(t, err) val, err := config.Get("example.com", "git_protocol") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "https", val) val, err = config.Get("github.com", "git_protocol") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "ssh", val) val, err = config.Get("nonexistent.io", "git_protocol") - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "ssh", val) } @@ -100,7 +100,7 @@ github.com: defer StubBackupConfig()() _, err := ParseConfig("config.yml") - assert.Nil(t, err) + assert.NoError(t, err) expectedHosts := `github.com: user: keiyuri diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index 47295230f..fca819f46 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -55,15 +55,15 @@ func Test_defaultConfig(t *testing.T) { assert.Equal(t, "", hostsBuf.String()) proto, err := cfg.Get("", "git_protocol") - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, "https", proto) editor, err := cfg.Get("", "editor") - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, "", editor) aliases, err := cfg.Aliases() - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, len(aliases.All()), 1) expansion, _ := aliases.Get("co") assert.Equal(t, expansion, "pr checkout") @@ -74,13 +74,13 @@ func Test_ValidateValue(t *testing.T) { assert.EqualError(t, err, "invalid value") err = ValidateValue("git_protocol", "ssh") - assert.Nil(t, err) + assert.NoError(t, err) err = ValidateValue("editor", "vim") - assert.Nil(t, err) + assert.NoError(t, err) err = ValidateValue("got", "123") - assert.Nil(t, err) + assert.NoError(t, err) } func Test_ValidateKey(t *testing.T) { diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go index c280b8a90..989cb3e3c 100644 --- a/internal/config/from_env_test.go +++ b/internal/config/from_env_test.go @@ -279,7 +279,9 @@ func TestInheritEnv(t *testing.T) { assert.Equal(t, tt.wants.token, val) err := cfg.CheckWriteable(tt.hostname, "oauth_token") - assert.Equal(t, tt.wants.writeable, err == nil) + if tt.wants.writeable != (err == nil) { + t.Errorf("CheckWriteable() = %v, wants %v", err, tt.wants.writeable) + } }) } } diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 787569c68..a392e4ad2 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) { assert.Error(t, err) return } - assert.Equal(t, nil, err) + assert.NoError(t, err) }) } } diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go index 3c6acea28..6348b361c 100644 --- a/pkg/cmd/alias/delete/delete_test.go +++ b/pkg/cmd/alias/delete/delete_test.go @@ -76,9 +76,7 @@ func TestAliasDelete(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr != "" { - if assert.Error(t, err) { - assert.Equal(t, tt.wantErr, err.Error()) - } + assert.EqualError(t, err, tt.wantErr) return } require.NoError(t, err) diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go index 95f3a5031..c440d80c1 100644 --- a/pkg/cmd/alias/set/set_test.go +++ b/pkg/cmd/alias/set/set_test.go @@ -65,10 +65,7 @@ func TestAliasSet_gh_command(t *testing.T) { cfg := config.NewFromString(``) _, err := runCommand(cfg, true, "pr 'pr status'") - - if assert.Error(t, err) { - assert.Equal(t, `could not create alias: "pr" is already a gh command`, err.Error()) - } + assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`) } func TestAliasSet_empty_aliases(t *testing.T) { @@ -210,9 +207,7 @@ func TestAliasSet_invalid_command(t *testing.T) { cfg := config.NewFromString(``) _, err := runCommand(cfg, true, "co 'pe checkout'") - if assert.Error(t, err) { - assert.Equal(t, "could not create alias: pe checkout does not correspond to a gh command", err.Error()) - } + assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command") } func TestShellAlias_flag(t *testing.T) { diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 1c33dfcc2..2a61efdfa 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -193,7 +193,7 @@ func Test_loginRun_nontty(t *testing.T) { opts *LoginOptions httpStubs func(*httpmock.Registry) wantHosts string - wantErr *regexp.Regexp + wantErr string }{ { name: "with token", @@ -223,7 +223,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) }, - wantErr: regexp.MustCompile(`missing required scope 'repo'`), + wantErr: `could not validate token: missing required scope 'repo'`, }, { name: "missing read scope", @@ -234,7 +234,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo")) }, - wantErr: regexp.MustCompile(`missing required scope 'read:org'`), + wantErr: `could not validate token: missing required scope 'read:org'`, }, { name: "has admin scope", @@ -282,14 +282,10 @@ func Test_loginRun_nontty(t *testing.T) { defer config.StubWriteConfig(&mainBuf, &hostsBuf)() err := loginRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - assert.True(t, tt.wantErr.MatchString(err.Error())) - return - } else { - t.Fatalf("unexpected error: %s", err) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stdout.String()) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index a9d14cc91..03b439ac0 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -98,7 +98,7 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts []string wantHosts string wantErrOut *regexp.Regexp - wantErr *regexp.Regexp + wantErr string }{ { name: "no arguments, multiple hosts", @@ -123,7 +123,7 @@ func Test_logoutRun_tty(t *testing.T) { { name: "no arguments, no hosts", opts: &LogoutOptions{}, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, { name: "hostname", @@ -176,14 +176,11 @@ func Test_logoutRun_tty(t *testing.T) { } err := logoutRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - assert.True(t, tt.wantErr.MatchString(err.Error())) - return - } else { - t.Fatalf("unexpected error: %s", err) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } else { + assert.NoError(t, err) } if tt.wantErrOut == nil { @@ -204,7 +201,7 @@ func Test_logoutRun_nontty(t *testing.T) { opts *LogoutOptions cfgHosts []string wantHosts string - wantErr *regexp.Regexp + wantErr string ghtoken string }{ { @@ -227,7 +224,7 @@ func Test_logoutRun_nontty(t *testing.T) { opts: &LogoutOptions{ Hostname: "harry.mason", }, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, } @@ -258,16 +255,10 @@ func Test_logoutRun_nontty(t *testing.T) { defer config.StubWriteConfig(&mainBuf, &hostsBuf)() err := logoutRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - if !tt.wantErr.MatchString(err.Error()) { - t.Errorf("got error: %v", err) - } - return - } else { - t.Fatalf("unexpected error: %s", err) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stderr.String()) diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 42bb2f202..e11592e23 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -2,7 +2,6 @@ package refresh import ( "bytes" - "regexp" "testing" "github.com/cli/cli/internal/config" @@ -134,14 +133,14 @@ func Test_refreshRun(t *testing.T) { opts *RefreshOptions askStubs func(*prompt.AskStubber) cfgHosts []string - wantErr *regexp.Regexp + wantErr string nontty bool wantAuthArgs authArgs }{ { name: "no hosts configured", opts: &RefreshOptions{}, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, { name: "hostname given but dne", @@ -152,7 +151,7 @@ func Test_refreshRun(t *testing.T) { opts: &RefreshOptions{ Hostname: "obed.morton", }, - wantErr: regexp.MustCompile(`not logged in to obed.morton`), + wantErr: `not logged in to obed.morton`, }, { name: "hostname provided and is configured", @@ -250,14 +249,12 @@ func Test_refreshRun(t *testing.T) { } err := refreshRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - assert.True(t, tt.wantErr.MatchString(err.Error())) - return - } else { - t.Fatalf("unexpected error: %s", err) + if tt.wantErr != "" { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.wantErr) } + } else { + assert.NoError(t, err) } assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 6974770dc..b16e4df10 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -78,7 +78,7 @@ func Test_statusRun(t *testing.T) { opts *StatusOptions httpStubs func(*httpmock.Registry) cfg func(config.Config) - wantErr *regexp.Regexp + wantErr string wantErrOut *regexp.Regexp }{ { @@ -113,7 +113,7 @@ func Test_statusRun(t *testing.T) { httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) }, wantErrOut: regexp.MustCompile(`joel.miller: missing required.*Logged in to github.com as.*tess`), - wantErr: regexp.MustCompile(``), + wantErr: "SilentError", }, { name: "bad token", @@ -130,7 +130,7 @@ func Test_statusRun(t *testing.T) { httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) }, wantErrOut: regexp.MustCompile(`joel.miller: authentication failed.*Logged in to github.com as.*tess`), - wantErr: regexp.MustCompile(``), + wantErr: "SilentError", }, { name: "all good", @@ -236,14 +236,11 @@ func Test_statusRun(t *testing.T) { defer config.StubWriteConfig(&mainBuf, &hostsBuf)() err := statusRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - assert.True(t, tt.wantErr.MatchString(err.Error())) - return - } else { - t.Fatalf("unexpected error: %s", err) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } else { + assert.NoError(t, err) } if tt.wantErrOut == nil { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 9910bc7cd..73a28dc46 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -75,11 +75,7 @@ func TestIssueCreate_nontty_error(t *testing.T) { defer http.Verify(t) _, err := runCommand(http, false, `-t hello`) - if err == nil { - t.Fatal("expected error running command `issue create`") - } - - assert.Equal(t, "must provide --title and --body when not running interactively", err.Error()) + assert.EqualError(t, err, "must provide --title and --body when not running interactively") } func TestIssueCreate(t *testing.T) { diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index bc9f087aa..7513e69fe 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -166,7 +166,7 @@ func TestPRCheckout_urlArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 4, len(ranCommands)) @@ -205,7 +205,7 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) @@ -257,7 +257,7 @@ func TestPRCheckout_branchArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `hubot:feature`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 5, len(ranCommands)) @@ -296,7 +296,7 @@ func TestPRCheckout_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 3, len(ranCommands)) @@ -348,7 +348,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer restoreCmd() output, err := runCommand(http, remotes, "master", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 4, len(ranCommands)) @@ -390,7 +390,7 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 4, len(ranCommands)) @@ -432,7 +432,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 2, len(ranCommands)) @@ -472,7 +472,7 @@ func TestPRCheckout_detachedHead(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 2, len(ranCommands)) @@ -512,7 +512,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "feature", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 2, len(ranCommands)) @@ -546,9 +546,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - if assert.Errorf(t, err, "expected command to fail") { - assert.Equal(t, `invalid branch name: "-foo"`, err.Error()) - } + assert.EqualError(t, err, `invalid branch name: "-foo"`) assert.Equal(t, "", output.Stderr()) } @@ -584,7 +582,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 4, len(ranCommands)) @@ -625,7 +623,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123 --recurse-submodules`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "", output.String()) assert.Equal(t, 5, len(ranCommands)) diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 64565a963..4e9167353 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -195,10 +195,10 @@ func Test_checksRun(t *testing.T) { } err := checksRun(opts) - if err != nil { - assert.Equal(t, tt.wantErr, err.Error()) - } else if tt.wantErr != "" { - t.Errorf("expected %q, got nil error", tt.wantErr) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, tt.wantOut, stdout.String()) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index c5134d102..939e4116b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -121,11 +121,7 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { defer http.Verify(t) output, err := runCommand(http, nil, "feature", false, "") - if err == nil { - t.Fatal("expected error") - } - - assert.Equal(t, "--title or --fill required when not running interactively", err.Error()) + assert.EqualError(t, err, "--title or --fill required when not running interactively") assert.Equal(t, "", output.String()) } @@ -634,7 +630,7 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) - assert.Equal(t, nil, err) + assert.NoError(t, err) assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 6b6af4eaa..2e81116a4 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -174,10 +174,7 @@ func TestPRDiff_no_current_pr(t *testing.T) { ) _, err := runCommand(http, nil, false, "") - if err == nil { - t.Fatal("expected error") - } - assert.Equal(t, `no pull requests found for branch "feature"`, err.Error()) + assert.EqualError(t, err, `no pull requests found for branch "feature"`) } func TestPRDiff_argument_not_found(t *testing.T) { @@ -197,10 +194,7 @@ func TestPRDiff_argument_not_found(t *testing.T) { ) _, err := runCommand(http, nil, false, "123") - if err == nil { - t.Fatal("expected error", err) - } - assert.Equal(t, `could not find pull request diff: pull request not found`, err.Error()) + assert.EqualError(t, err, `could not find pull request diff: pull request not found`) } func TestPRDiff_notty(t *testing.T) { diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 749495c7a..21e513412 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -470,10 +470,7 @@ func TestPRReview_interactive_no_body(t *testing.T) { }) _, err := runCommand(http, nil, true, "") - if err == nil { - t.Fatal("expected error") - } - assert.Equal(t, "this type of review cannot be blank", err.Error()) + assert.EqualError(t, err, "this type of review cannot be blank") } func TestPRReview_interactive_blank_approve(t *testing.T) { diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index f6b612202..db40f4f2d 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -77,11 +77,11 @@ func TestNewCmdClone(t *testing.T) { cmd.SetErr(stderr) _, err = cmd.ExecuteC() - if err != nil { - assert.Equal(t, tt.wantErr, err.Error()) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) return - } else if tt.wantErr != "" { - t.Errorf("expected error %q, got nil", tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stdout.String()) From c9407b2629219021925df4290efa1c8ed207145a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 Jan 2021 18:13:19 +0100 Subject: [PATCH 124/129] More descriptive error when aborting auth due to environment variables Old message: read-only token in GH_TOKEN cannot be modified This message was vague and some users did not understand that this refers to the value that is read from environment variables. New message: $ GH_TOKEN=123 ghd auth login -h github.com The value of the GH_TOKEN environment variable is being used for authentication. To have GitHub CLI store credentials instead, first clear the value from the environment. --- internal/config/from_env.go | 10 ++- pkg/cmd/auth/login/login.go | 125 +++++++++++++++++-------------- pkg/cmd/auth/login/login_test.go | 75 ++++++++++++++++--- pkg/cmd/auth/logout/logout.go | 6 ++ pkg/cmd/auth/refresh/refresh.go | 6 ++ pkg/cmd/auth/shared/client.go | 14 ---- 6 files changed, 152 insertions(+), 84 deletions(-) diff --git a/internal/config/from_env.go b/internal/config/from_env.go index 333dc879b..7b2853bd7 100644 --- a/internal/config/from_env.go +++ b/internal/config/from_env.go @@ -14,6 +14,14 @@ const ( GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN" ) +type ReadOnlyEnvError struct { + Variable string +} + +func (e *ReadOnlyEnvError) Error() string { + return fmt.Sprintf("read-only value in %s", e.Variable) +} + func InheritEnv(c Config) Config { return &envConfig{Config: c} } @@ -56,7 +64,7 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) func (c *envConfig) CheckWriteable(hostname, key string) error { if hostname != "" && key == "oauth_token" { if token, env := AuthTokenFromEnv(hostname); token != "" { - return fmt.Errorf("read-only token in %s cannot be modified", env) + return &ReadOnlyEnvError{Variable: env} } } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index c4aed6c20..eb3b6b645 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -120,70 +120,56 @@ func loginRun(opts *LoginOptions) error { return err } + hostname := opts.Hostname + if hostname == "" { + if opts.Interactive { + var err error + hostname, err = promptForHostname() + if err != nil { + return err + } + } else { + return errors.New("must specify --hostname") + } + } + + if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n") + return cmdutil.SilentError + } + return err + } + if opts.Token != "" { - // I chose to not error on existing host here; my thinking is that for --with-token the user - // probably doesn't care if a token is overwritten since they have a token in hand they - // explicitly want to use. - if opts.Hostname == "" { - return errors.New("empty hostname would leak oauth_token") - } - - err := cfg.Set(opts.Hostname, "oauth_token", opts.Token) + err := cfg.Set(hostname, "oauth_token", opts.Token) if err != nil { return err } - err = shared.ValidateHostCfg(opts.Hostname, cfg) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } + if err := apiClient.HasMinimumScopes(hostname); err != nil { + return fmt.Errorf("error validating token: %w", err) + } + return cfg.Write() } - // TODO consider explicitly telling survey what io to use since it's implicit right now - - hostname := opts.Hostname - - if hostname == "" { - var hostType int - err := prompt.SurveyAskOne(&survey.Select{ - Message: "What account do you want to log into?", - Options: []string{ - "GitHub.com", - "GitHub Enterprise Server", - }, - }, &hostType) - - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - - isEnterprise := hostType == 1 - - hostname = ghinstance.Default() - if isEnterprise { - err := prompt.SurveyAskOne(&survey.Input{ - Message: "GHE hostname:", - }, &hostname, survey.WithValidator(ghinstance.HostnameValidator)) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - } - } - - fmt.Fprintf(opts.IO.ErrOut, "- Logging into %s\n", hostname) - existingToken, _ := cfg.Get(hostname, "oauth_token") if existingToken != "" && opts.Interactive { - err := shared.ValidateHostCfg(hostname, cfg) - if err == nil { - apiClient, err := shared.ClientFromCfg(hostname, cfg) - if err != nil { - return err - } + apiClient, err := shared.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + if err := apiClient.HasMinimumScopes(hostname); err == nil { username, err := api.CurrentLoginName(apiClient, hostname) if err != nil { return fmt.Errorf("error using api: %w", err) @@ -206,10 +192,6 @@ func loginRun(opts *LoginOptions) error { } } - if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { - return err - } - var authMode int if opts.Web { authMode = 0 @@ -244,19 +226,19 @@ func loginRun(opts *LoginOptions) error { return fmt.Errorf("could not prompt: %w", err) } - if hostname == "" { - return errors.New("empty hostname would leak oauth_token") - } - err = cfg.Set(hostname, "oauth_token", token) if err != nil { return err } - err = shared.ValidateHostCfg(hostname, cfg) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } + + if err := apiClient.HasMinimumScopes(hostname); err != nil { + return fmt.Errorf("error validating token: %w", err) + } } cs := opts.IO.ColorScheme() @@ -322,6 +304,35 @@ func loginRun(opts *LoginOptions) error { return nil } +func promptForHostname() (string, error) { + var hostType int + err := prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to log into?", + Options: []string{ + "GitHub.com", + "GitHub Enterprise Server", + }, + }, &hostType) + + if err != nil { + return "", fmt.Errorf("could not prompt: %w", err) + } + + isEnterprise := hostType == 1 + + hostname := ghinstance.Default() + if isEnterprise { + err := prompt.SurveyAskOne(&survey.Input{ + Message: "GHE hostname:", + }, &hostname, survey.WithValidator(ghinstance.HostnameValidator)) + if err != nil { + return "", fmt.Errorf("could not prompt: %w", err) + } + } + + return hostname, nil +} + func getAccessTokenTip(hostname string) string { ghHostname := hostname if ghHostname == "" { diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 2a61efdfa..0705eeffe 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -3,9 +3,11 @@ package login import ( "bytes" "net/http" + "os" "regexp" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmd/auth/shared" @@ -189,11 +191,13 @@ func Test_NewCmdLogin(t *testing.T) { func Test_loginRun_nontty(t *testing.T) { tests := []struct { - name string - opts *LoginOptions - httpStubs func(*httpmock.Registry) - wantHosts string - wantErr string + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + env map[string]string + wantHosts string + wantErr string + wantStderr string }{ { name: "with token", @@ -201,6 +205,9 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "github.com", Token: "abc123", }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + }, wantHosts: "github.com:\n oauth_token: abc123\n", }, { @@ -223,7 +230,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) }, - wantErr: `could not validate token: missing required scope 'repo'`, + wantErr: `error validating token: missing required scope 'repo'`, }, { name: "missing read scope", @@ -234,7 +241,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo")) }, - wantErr: `could not validate token: missing required scope 'read:org'`, + wantErr: `error validating token: missing required scope 'read:org'`, }, { name: "has admin scope", @@ -247,6 +254,36 @@ func Test_loginRun_nontty(t *testing.T) { }, wantHosts: "github.com:\n oauth_token: abc456\n", }, + { + name: "github.com token from environment", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + env: map[string]string{ + "GH_TOKEN": "value_from_env", + }, + wantErr: "SilentError", + wantStderr: heredoc.Doc(` + The value of the GH_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), + }, + { + name: "GHE token from environment", + opts: &LoginOptions{ + Hostname: "ghe.io", + Token: "abc456", + }, + env: map[string]string{ + "GH_ENTERPRISE_TOKEN": "value_from_env", + }, + wantErr: "SilentError", + wantStderr: heredoc.Doc(` + The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), + }, } for _, tt := range tests { @@ -256,7 +293,8 @@ func Test_loginRun_nontty(t *testing.T) { io.SetStdoutTTY(false) tt.opts.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil + cfg := config.NewBlankConfig() + return config.InheritEnv(cfg), nil } tt.opts.IO = io @@ -271,10 +309,23 @@ func Test_loginRun_nontty(t *testing.T) { return api.NewClientFromHTTP(httpClient), nil } + old_GH_TOKEN := os.Getenv("GH_TOKEN") + os.Setenv("GH_TOKEN", tt.env["GH_TOKEN"]) + old_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + os.Setenv("GITHUB_TOKEN", tt.env["GITHUB_TOKEN"]) + old_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN") + os.Setenv("GH_ENTERPRISE_TOKEN", tt.env["GH_ENTERPRISE_TOKEN"]) + old_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.env["GITHUB_ENTERPRISE_TOKEN"]) + defer func() { + os.Setenv("GH_TOKEN", old_GH_TOKEN) + os.Setenv("GITHUB_TOKEN", old_GITHUB_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", old_GH_ENTERPRISE_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", old_GITHUB_ENTERPRISE_TOKEN) + }() + if tt.httpStubs != nil { tt.httpStubs(reg) - } else { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) } mainBuf := bytes.Buffer{} @@ -289,7 +340,7 @@ func Test_loginRun_nontty(t *testing.T) { } assert.Equal(t, "", stdout.String()) - assert.Equal(t, "", stderr.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) assert.Equal(t, tt.wantHosts, hostsBuf.String()) reg.Verify(t) }) @@ -325,7 +376,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(false) // do not continue }, wantHosts: "", // nothing should have been written to hosts - wantErrOut: regexp.MustCompile("Logging into github.com"), + wantErrOut: nil, }, { name: "hostname set", diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 699de3968..1454a3656 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -105,6 +105,12 @@ func logoutRun(opts *LogoutOptions) error { } if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To erase credentials stored in GitHub CLI, first clear the value from the environment.\n") + return cmdutil.SilentError + } return err } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 621b717cc..11673c58c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -112,6 +112,12 @@ func refreshRun(opts *RefreshOptions) error { } if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To refresh credentials stored in GitHub CLI, first clear the value from the environment.\n") + return cmdutil.SilentError + } return err } diff --git a/pkg/cmd/auth/shared/client.go b/pkg/cmd/auth/shared/client.go index 217254237..87acb0a71 100644 --- a/pkg/cmd/auth/shared/client.go +++ b/pkg/cmd/auth/shared/client.go @@ -8,20 +8,6 @@ import ( "github.com/cli/cli/internal/config" ) -func ValidateHostCfg(hostname string, cfg config.Config) error { - apiClient, err := ClientFromCfg(hostname, cfg) - if err != nil { - return err - } - - err = apiClient.HasMinimumScopes(hostname) - if err != nil { - return fmt.Errorf("could not validate token: %w", err) - } - - return nil -} - var ClientFromCfg = func(hostname string, cfg config.Config) (*api.Client, error) { var opts []api.ClientOption From 2086d135f3cf5fa7e4b99828040d84d2880b3eeb Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Wed, 20 Jan 2021 18:44:46 +0000 Subject: [PATCH 125/129] Respect system/user timezone in API requests (#2630) * Respect system/user timezone in API requests * Fall back to a known timezone if TZ is not set Co-authored-by: Cristian Dominguez --- pkg/cmd/factory/http.go | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 47fbbefe8..8cd1bf12a 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" @@ -12,6 +13,46 @@ import ( "github.com/cli/cli/pkg/iostreams" ) +var timezoneNames = map[int]string{ + -39600: "Pacific/Niue", + -36000: "Pacific/Honolulu", + -34200: "Pacific/Marquesas", + -32400: "America/Anchorage", + -28800: "America/Los_Angeles", + -25200: "America/Chihuahua", + -21600: "America/Chicago", + -18000: "America/Bogota", + -14400: "America/Caracas", + -12600: "America/St_Johns", + -10800: "America/Argentina/Buenos_Aires", + -7200: "Atlantic/South_Georgia", + -3600: "Atlantic/Cape_Verde", + 0: "Europe/London", + 3600: "Europe/Amsterdam", + 7200: "Europe/Athens", + 10800: "Europe/Istanbul", + 12600: "Asia/Tehran", + 14400: "Asia/Dubai", + 16200: "Asia/Kabul", + 18000: "Asia/Tashkent", + 19800: "Asia/Kolkata", + 20700: "Asia/Kathmandu", + 21600: "Asia/Dhaka", + 23400: "Asia/Rangoon", + 25200: "Asia/Bangkok", + 28800: "Asia/Manila", + 31500: "Australia/Eucla", + 32400: "Asia/Tokyo", + 34200: "Australia/Darwin", + 36000: "Australia/Brisbane", + 37800: "Australia/Adelaide", + 39600: "Pacific/Guadalcanal", + 43200: "Pacific/Nauru", + 46800: "Pacific/Auckland", + 49500: "Pacific/Chatham", + 50400: "Pacific/Kiritimati", +} + // generic authenticated HTTP client for commands func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { var opts []api.ClientOption @@ -29,6 +70,16 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string } return "", nil }), + api.AddHeaderFunc("Time-Zone", func(req *http.Request) (string, error) { + if req.Method != "GET" && req.Method != "HEAD" { + if time.Local.String() != "Local" { + return time.Local.String(), nil + } + _, offset := time.Now().Zone() + return timezoneNames[offset], nil + } + return "", nil + }), ) if setAccept { From e26a1b98a1ad832bdd97cbba90a0e4e98ad09892 Mon Sep 17 00:00:00 2001 From: edualb Date: Fri, 18 Sep 2020 18:27:27 -0300 Subject: [PATCH 126/129] add ssh-key command --- api/client.go | 11 +- internal/authflow/flow.go | 2 +- pkg/cmd/auth/login/login_test.go | 16 +- pkg/cmd/auth/status/status_test.go | 20 +- pkg/cmd/root/root.go | 2 + pkg/cmd/ssh-key/list/list.go | 152 +++++++++++++++ pkg/cmd/ssh-key/list/list_test.go | 289 +++++++++++++++++++++++++++++ pkg/cmd/ssh-key/ssh-key.go | 20 ++ 8 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 pkg/cmd/ssh-key/list/list.go create mode 100644 pkg/cmd/ssh-key/list/list_test.go create mode 100644 pkg/cmd/ssh-key/ssh-key.go diff --git a/api/client.go b/api/client.go index 09195181b..3470159f2 100644 --- a/api/client.go +++ b/api/client.go @@ -203,9 +203,10 @@ func (c Client) HasMinimumScopes(hostname string) error { } search := map[string]bool{ - "repo": false, - "read:org": false, - "admin:org": false, + "repo": false, + "read:org": false, + "admin:org": false, + "read:public_key": false, } for _, s := range strings.Split(scopesHeader, ",") { search[strings.TrimSpace(s)] = true @@ -220,6 +221,10 @@ func (c Client) HasMinimumScopes(hostname string) error { missingScopes = append(missingScopes, "read:org") } + if !search["read:public_key"] && !search["admin:public_key"] { + missingScopes = append(missingScopes, "read:public_key") + } + if len(missingScopes) > 0 { return &MissingScopesError{MissingScopes: missingScopes} } diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index fac9d31a6..b3a9bce99 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -65,7 +65,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport) } - minimumScopes := []string{"repo", "read:org", "gist", "workflow"} + minimumScopes := []string{"repo", "read:org", "gist", "workflow", "read:public_key"} scopes := append(minimumScopes, additionalScopes...) callbackURI := "http://127.0.0.1/callback" diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 2a61efdfa..1b7207333 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -210,7 +210,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc123", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) }, wantHosts: "albert.wesker:\n oauth_token: abc123\n", }, @@ -221,7 +221,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org,read:public_key")) }, wantErr: `could not validate token: missing required scope 'repo'`, }, @@ -243,7 +243,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org,read:public_key")) }, wantHosts: "github.com:\n oauth_token: abc456\n", }, @@ -274,7 +274,7 @@ func Test_loginRun_nontty(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } else { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) } mainBuf := bytes.Buffer{} @@ -315,7 +315,7 @@ func Test_loginRun_Survey(t *testing.T) { _ = cfg.Set("github.com", "oauth_token", "ghi789") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -341,7 +341,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -363,7 +363,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -436,7 +436,7 @@ func Test_loginRun_Survey(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } else { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index b16e4df10..e2ffef4fe 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "abc123") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -106,8 +106,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "abc123") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -124,7 +124,7 @@ func Test_statusRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "abc123") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index d5e509665..ba05f902c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -20,6 +20,7 @@ import ( repoCmd "github.com/cli/cli/pkg/cmd/repo" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" secretCmd "github.com/cli/cli/pkg/cmd/secret" + sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key" versionCmd "github.com/cli/cli/pkg/cmd/version" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -76,6 +77,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) cmd.AddCommand(secretCmd.NewCmdSecret(f)) + cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) // the `api` command should not inherit any extra HTTP headers bareHTTPCmdFactory := *f diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go new file mode 100644 index 000000000..bd7907f9b --- /dev/null +++ b/pkg/cmd/ssh-key/list/list.go @@ -0,0 +1,152 @@ +package list + +import ( + "bytes" + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/utils" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +// ListOptions struct for list command +type ListOptions struct { + HTTPClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + + ListMsg []string +} + +// NewCmdList creates a command for list all SSH Keys +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + HTTPClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + + ListMsg: []string{}, + } + + cmd := &cobra.Command{ + Use: "list", + Args: cobra.ExactArgs(0), + Short: "Lists currently added ssh keys", + Long: heredoc.Doc(`Lists currently added ssh keys. + + This interactive command lists all SSH keys associated with your account + `), + Example: heredoc.Doc(` + $ gh ssh-key list + # => lists all ssh keys associated with your account + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + apiClient, err := opts.getAPIClient() + if err != nil { + opts.printTerminal() + return err + } + + err = opts.hasMinimumScopes(apiClient) + if err != nil { + opts.printTerminal() + return err + } + + type keys struct { + Title string + Key string + } + + type result []keys + + rs := result{} + body := bytes.NewBufferString("") + + err = apiClient.REST(ghinstance.Default(), "GET", "user/keys", body, &rs) + if err != nil { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: Got %s", utils.RedX(), err)) + opts.printTerminal() + return err + } + + for _, r := range rs { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s %s: %s \n %s: %s", utils.Cyan("✹"), utils.Bold("Name"), r.Title, utils.Bold("SSH-KEY"), r.Key)) + } + + opts.printTerminal() + + return nil +} + +func (opts *ListOptions) getAPIClient() (*api.Client, error) { + httpClient, err := opts.HTTPClient() + if err != nil { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) + return nil, err + } + return api.NewClientFromHTTP(httpClient), nil +} + +func (opts *ListOptions) hasMinimumScopes(apiClient *api.Client) error { + cfg, err := opts.Config() + if err != nil { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) + return err + } + + hostname := ghinstance.Default() + + _, tokenSource, _ := cfg.GetWithSource(hostname, "oauth_token") + + // TODO: Implement tests for this case when CheckWriteable function checks filesystem permissions + tokenIsWriteable := cfg.CheckWriteable(hostname, "oauth_token") == nil + + err = apiClient.HasMinimumScopes(hostname) + + if err != nil { + var missingScopes *api.MissingScopesError + if errors.As(err, &missingScopes) { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) + if tokenIsWriteable { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To request missing scopes, run: %s %s", utils.Bold("gh auth refresh -h"), hostname)) + } + } else { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: authentication failed", utils.RedX())) + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- The %s token in %s is no longer valid.", utils.Bold(hostname), utils.Bold(tokenSource))) + if tokenIsWriteable { + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To re-authenticate, run: %s %s", utils.Bold("gh auth login -h"), utils.Bold(hostname))) + opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To forget about this host, run: %s %s", utils.Bold("gh auth logout -h"), utils.Bold(hostname))) + } + } + return err + } + + return nil +} + +func (opts *ListOptions) printTerminal() { + stderr := opts.IO.ErrOut + for _, line := range opts.ListMsg { + fmt.Fprintf(stderr, " %s\n", line) + } +} diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go new file mode 100644 index 000000000..f92557107 --- /dev/null +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -0,0 +1,289 @@ +package list + +import ( + "bytes" + "errors" + "net/http" + "reflect" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" +) + +func TestCmdList(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + httpFunc := func() (*http.Client, error) { return nil, nil } + configFunc := func() (config.Config, error) { return nil, nil } + + type input struct { + cli string + httpClient func() (*http.Client, error) + io *iostreams.IOStreams + config func() (config.Config, error) + } + + tests := []struct { + name string + input input + wants ListOptions + }{ + { + name: "no arguments", + input: input{ + cli: "", + httpClient: httpFunc, + io: io, + config: configFunc, + }, + wants: ListOptions{ + HTTPClient: httpFunc, + Config: configFunc, + IO: io, + ListMsg: []string{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{ + HttpClient: tt.input.httpClient, + Config: tt.input.config, + IOStreams: tt.input.io, + } + + argv, err := shlex.Split(tt.input.cli) + if err != nil { + t.Errorf(`Split() = got %v`, err) + } + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if err != nil { + t.Errorf(`ExecuteC() = got %v`, err) + } + + if reflect.ValueOf(tt.wants.HTTPClient).Pointer() != reflect.ValueOf(gotOpts.HTTPClient).Pointer() { + t.Errorf(`HTTPClient has wrong values`) + } + + if reflect.ValueOf(tt.wants.Config).Pointer() != reflect.ValueOf(gotOpts.Config).Pointer() { + t.Errorf(`Config has wrong values`) + } + + if reflect.ValueOf(tt.wants.IO).Pointer() != reflect.ValueOf(gotOpts.IO).Pointer() { + t.Errorf(`IO has wrong values`) + } + + if !reflect.DeepEqual(tt.wants.ListMsg, gotOpts.ListMsg) { + t.Errorf(`ListMsg has wrong values: want %v, got %v`, tt.wants.ListMsg, gotOpts.ListMsg) + } + }) + } +} + +func TestListRun(t *testing.T) { + type input struct { + httpStubs func(*httpmock.Registry) + configError bool + httpClientError bool + hasOauthToken bool + wantErr bool + } + + tests := []struct { + name string + input input + want []string + }{ + { + name: "name and corresponding ssh key", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder("repo,read:org,read:public_key"), + ) + }, + false, + false, + true, + false, + }, + want: []string{"✹ Name: Mac \n SSH-KEY: ssh-rsa AAAABbBB123"}, + }, + { + name: "config error", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(""), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder("repo,read:org,read:public_key"), + ) + }, + true, + false, + true, + true, + }, + want: []string{"X: Config error"}, + }, + { + name: "http client error", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(""), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder("repo,read:org,read:public_key"), + ) + }, + false, + true, + true, + true, + }, + want: []string{"X: HttpClient error"}, + }, + { + name: "not found on api.github.com/user/keys", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder("repo,read:org,read:public_key"), + ) + }, + false, + false, + true, + true, + }, + want: []string{"X: Got HTTP 404 (https://api.github.com/user/keys)"}, + }, + { + name: "missing scope", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder(""), + ) + }, + false, + false, + true, + true, + }, + want: []string{ + "X: missing required scope 'repo';missing required scope 'read:org';missing required scope 'read:public_key'", + "- To request missing scopes, run: gh auth refresh -h github.com", + }, + }, + { + name: "authentication failed", + input: input{ + func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), + ) + reg.Register( + httpmock.REST("GET", ""), + httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`), + ) + }, + false, + false, + true, + true, + }, + want: []string{ + "X: authentication failed", + "- The github.com token in ~/.config/gh/hosts.yml is no longer valid.", + "- To re-authenticate, run: gh auth login -h github.com", + "- To forget about this host, run: gh auth logout -h github.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + tt.input.httpStubs(reg) + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + opts := ListOptions{ + HTTPClient: func() (*http.Client, error) { + if tt.input.httpClientError { + return nil, errors.New("HttpClient error") + } + return &http.Client{Transport: reg}, nil + }, + IO: io, + Config: func() (config.Config, error) { + if tt.input.configError { + return nil, errors.New("Config error") + } + cfg := config.NewBlankConfig() + if tt.input.hasOauthToken { + err := cfg.Set("github.com", "oauth_token", "abc123") + if err != nil { + return nil, err + } + } + return cfg, nil + }, + } + + err := listRun(&opts) + if err != nil && !tt.input.wantErr { + t.Errorf("linRun() return error: %v", err) + } + if !reflect.DeepEqual(opts.ListMsg, tt.want) { + t.Errorf("linRun() = want %v, got %v", tt.want, opts.ListMsg) + } + }) + } +} diff --git a/pkg/cmd/ssh-key/ssh-key.go b/pkg/cmd/ssh-key/ssh-key.go new file mode 100644 index 000000000..1d0b471e6 --- /dev/null +++ b/pkg/cmd/ssh-key/ssh-key.go @@ -0,0 +1,20 @@ +package key + +import ( + cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSSHKey creates a command for manage SSH Keys +func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "ssh-key ", + Short: "Manage SSH keys", + Long: "Work with GitHub SSH keys", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + + return cmd +} From 4a2cc8d2a467a5ce57a36de9475bd6325627291e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 18 Jan 2021 17:23:54 +0100 Subject: [PATCH 127/129] Simplify `ssh-key list` Do not require nor request `read:public_key` scope by default. --- api/client.go | 11 +- internal/authflow/flow.go | 2 +- pkg/cmd/auth/login/login_test.go | 12 +- pkg/cmd/auth/status/status_test.go | 18 +- pkg/cmd/ssh-key/list/http.go | 58 +++++ pkg/cmd/ssh-key/list/list.go | 144 ++++------- pkg/cmd/ssh-key/list/list_test.go | 368 ++++++++--------------------- 7 files changed, 226 insertions(+), 387 deletions(-) create mode 100644 pkg/cmd/ssh-key/list/http.go diff --git a/api/client.go b/api/client.go index 3470159f2..09195181b 100644 --- a/api/client.go +++ b/api/client.go @@ -203,10 +203,9 @@ func (c Client) HasMinimumScopes(hostname string) error { } search := map[string]bool{ - "repo": false, - "read:org": false, - "admin:org": false, - "read:public_key": false, + "repo": false, + "read:org": false, + "admin:org": false, } for _, s := range strings.Split(scopesHeader, ",") { search[strings.TrimSpace(s)] = true @@ -221,10 +220,6 @@ func (c Client) HasMinimumScopes(hostname string) error { missingScopes = append(missingScopes, "read:org") } - if !search["read:public_key"] && !search["admin:public_key"] { - missingScopes = append(missingScopes, "read:public_key") - } - if len(missingScopes) > 0 { return &MissingScopesError{MissingScopes: missingScopes} } diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index b3a9bce99..fac9d31a6 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -65,7 +65,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport) } - minimumScopes := []string{"repo", "read:org", "gist", "workflow", "read:public_key"} + minimumScopes := []string{"repo", "read:org", "gist", "workflow"} scopes := append(minimumScopes, additionalScopes...) callbackURI := "http://127.0.0.1/callback" diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 1b7207333..d1de06f6b 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -210,7 +210,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc123", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) }, wantHosts: "albert.wesker:\n oauth_token: abc123\n", }, @@ -221,7 +221,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) }, wantErr: `could not validate token: missing required scope 'repo'`, }, @@ -243,7 +243,7 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org")) }, wantHosts: "github.com:\n oauth_token: abc456\n", }, @@ -274,7 +274,7 @@ func Test_loginRun_nontty(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } else { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) } mainBuf := bytes.Buffer{} @@ -315,7 +315,7 @@ func Test_loginRun_Survey(t *testing.T) { _ = cfg.Set("github.com", "oauth_token", "ghi789") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -341,7 +341,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index e2ffef4fe..4eb37bd36 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "abc123") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -107,7 +107,7 @@ func Test_statusRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -124,7 +124,7 @@ func Test_statusRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "abc123") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) diff --git a/pkg/cmd/ssh-key/list/http.go b/pkg/cmd/ssh-key/list/http.go new file mode 100644 index 000000000..70a8d0578 --- /dev/null +++ b/pkg/cmd/ssh-key/list/http.go @@ -0,0 +1,58 @@ +package list + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" +) + +var scopesError = errors.New("insufficient OAuth scopes") + +type sshKey struct { + Key string + Title string + CreatedAt time.Time `json:"created_at"` +} + +func userKeys(httpClient *http.Client, userHandle string) ([]sshKey, error) { + resource := "user/keys" + if userHandle != "" { + resource = fmt.Sprintf("users/%s/keys", userHandle) + } + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(ghinstance.OverridableDefault()), resource, 100) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, scopesError + } else if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keys []sshKey + err = json.Unmarshal(b, &keys) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index bd7907f9b..93a9f5470 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -1,29 +1,21 @@ package list import ( - "bytes" "errors" "fmt" "net/http" + "time" - "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/utils" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" "github.com/spf13/cobra" ) // ListOptions struct for list command type ListOptions struct { - HTTPClient func() (*http.Client, error) IO *iostreams.IOStreams - Config func() (config.Config, error) - - ListMsg []string + HTTPClient func() (*http.Client, error) } // NewCmdList creates a command for list all SSH Keys @@ -31,23 +23,12 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ HTTPClient: f.HttpClient, IO: f.IOStreams, - Config: f.Config, - - ListMsg: []string{}, } cmd := &cobra.Command{ Use: "list", + Short: "Lists SSH keys in a GitHub account", Args: cobra.ExactArgs(0), - Short: "Lists currently added ssh keys", - Long: heredoc.Doc(`Lists currently added ssh keys. - - This interactive command lists all SSH keys associated with your account - `), - Example: heredoc.Doc(` - $ gh ssh-key list - # => lists all ssh keys associated with your account - `), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) @@ -60,93 +41,56 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } func listRun(opts *ListOptions) error { - apiClient, err := opts.getAPIClient() + apiClient, err := opts.HTTPClient() if err != nil { - opts.printTerminal() return err } - err = opts.hasMinimumScopes(apiClient) + sshKeys, err := userKeys(apiClient, "") if err != nil { - opts.printTerminal() - return err - } - - type keys struct { - Title string - Key string - } - - type result []keys - - rs := result{} - body := bytes.NewBufferString("") - - err = apiClient.REST(ghinstance.Default(), "GET", "user/keys", body, &rs) - if err != nil { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: Got %s", utils.RedX(), err)) - opts.printTerminal() - return err - } - - for _, r := range rs { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s %s: %s \n %s: %s", utils.Cyan("✹"), utils.Bold("Name"), r.Title, utils.Bold("SSH-KEY"), r.Key)) - } - - opts.printTerminal() - - return nil -} - -func (opts *ListOptions) getAPIClient() (*api.Client, error) { - httpClient, err := opts.HTTPClient() - if err != nil { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) - return nil, err - } - return api.NewClientFromHTTP(httpClient), nil -} - -func (opts *ListOptions) hasMinimumScopes(apiClient *api.Client) error { - cfg, err := opts.Config() - if err != nil { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) - return err - } - - hostname := ghinstance.Default() - - _, tokenSource, _ := cfg.GetWithSource(hostname, "oauth_token") - - // TODO: Implement tests for this case when CheckWriteable function checks filesystem permissions - tokenIsWriteable := cfg.CheckWriteable(hostname, "oauth_token") == nil - - err = apiClient.HasMinimumScopes(hostname) - - if err != nil { - var missingScopes *api.MissingScopesError - if errors.As(err, &missingScopes) { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err)) - if tokenIsWriteable { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To request missing scopes, run: %s %s", utils.Bold("gh auth refresh -h"), hostname)) - } - } else { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: authentication failed", utils.RedX())) - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- The %s token in %s is no longer valid.", utils.Bold(hostname), utils.Bold(tokenSource))) - if tokenIsWriteable { - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To re-authenticate, run: %s %s", utils.Bold("gh auth login -h"), utils.Bold(hostname))) - opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To forget about this host, run: %s %s", utils.Bold("gh auth logout -h"), utils.Bold(hostname))) - } + if errors.Is(err, scopesError) { + cs := opts.IO.ColorScheme() + fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n") + fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:public_key")) + return cmdutil.SilentError } return err } - return nil + if len(sshKeys) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "No SSH keys present in GitHub account.") + return cmdutil.SilentError + } + + t := utils.NewTablePrinter(opts.IO) + cs := opts.IO.ColorScheme() + now := time.Now() + + for _, sshKey := range sshKeys { + t.AddField(sshKey.Title, nil, nil) + t.AddField(sshKey.Key, truncateMiddle, nil) + + createdAt := sshKey.CreatedAt.Format(time.RFC3339) + if t.IsTTY() { + createdAt = utils.FuzzyAgoAbbr(now, sshKey.CreatedAt) + } + t.AddField(createdAt, nil, cs.Gray) + t.EndRow() + } + + return t.Render() } -func (opts *ListOptions) printTerminal() { - stderr := opts.IO.ErrOut - for _, line := range opts.ListMsg { - fmt.Fprintf(stderr, " %s\n", line) +func truncateMiddle(maxWidth int, t string) string { + if len(t) <= maxWidth { + return t } + + ellipsis := "..." + if maxWidth < len(ellipsis)+2 { + return t[0:maxWidth] + } + + halfWidth := (maxWidth - len(ellipsis)) / 2 + return t[0:halfWidth] + ellipsis + t[len(t)-halfWidth:] } diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go index f92557107..9dd261d9d 100644 --- a/pkg/cmd/ssh-key/list/list_test.go +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -1,288 +1,130 @@ package list import ( - "bytes" - "errors" + "fmt" "net/http" - "reflect" "testing" + "time" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/cmdutil" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" - "github.com/google/shlex" ) -func TestCmdList(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - io.SetStderrTTY(true) - - httpFunc := func() (*http.Client, error) { return nil, nil } - configFunc := func() (config.Config, error) { return nil, nil } - - type input struct { - cli string - httpClient func() (*http.Client, error) - io *iostreams.IOStreams - config func() (config.Config, error) - } - - tests := []struct { - name string - input input - wants ListOptions - }{ - { - name: "no arguments", - input: input{ - cli: "", - httpClient: httpFunc, - io: io, - config: configFunc, - }, - wants: ListOptions{ - HTTPClient: httpFunc, - Config: configFunc, - IO: io, - ListMsg: []string{}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f := &cmdutil.Factory{ - HttpClient: tt.input.httpClient, - Config: tt.input.config, - IOStreams: tt.input.io, - } - - argv, err := shlex.Split(tt.input.cli) - if err != nil { - t.Errorf(`Split() = got %v`, err) - } - - var gotOpts *ListOptions - cmd := NewCmdList(f, func(opts *ListOptions) error { - gotOpts = opts - return nil - }) - - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - _, err = cmd.ExecuteC() - if err != nil { - t.Errorf(`ExecuteC() = got %v`, err) - } - - if reflect.ValueOf(tt.wants.HTTPClient).Pointer() != reflect.ValueOf(gotOpts.HTTPClient).Pointer() { - t.Errorf(`HTTPClient has wrong values`) - } - - if reflect.ValueOf(tt.wants.Config).Pointer() != reflect.ValueOf(gotOpts.Config).Pointer() { - t.Errorf(`Config has wrong values`) - } - - if reflect.ValueOf(tt.wants.IO).Pointer() != reflect.ValueOf(gotOpts.IO).Pointer() { - t.Errorf(`IO has wrong values`) - } - - if !reflect.DeepEqual(tt.wants.ListMsg, gotOpts.ListMsg) { - t.Errorf(`ListMsg has wrong values: want %v, got %v`, tt.wants.ListMsg, gotOpts.ListMsg) - } - }) - } -} - func TestListRun(t *testing.T) { - type input struct { - httpStubs func(*httpmock.Registry) - configError bool - httpClientError bool - hasOauthToken bool - wantErr bool - } - tests := []struct { - name string - input input - want []string + name string + opts ListOptions + isTTY bool + wantStdout string + wantStderr string + wantErr bool }{ { - name: "name and corresponding ssh key", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.ScopesResponder("repo,read:org,read:public_key"), - ) - }, - false, - false, - true, - false, - }, - want: []string{"✹ Name: Mac \n SSH-KEY: ssh-rsa AAAABbBB123"}, - }, - { - name: "config error", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StringResponse(""), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.ScopesResponder("repo,read:org,read:public_key"), - ) - }, - true, - false, - true, - true, - }, - want: []string{"X: Config error"}, - }, - { - name: "http client error", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StringResponse(""), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.ScopesResponder("repo,read:org,read:public_key"), - ) - }, - false, - true, - true, - true, - }, - want: []string{"X: HttpClient error"}, - }, - { - name: "not found on api.github.com/user/keys", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.ScopesResponder("repo,read:org,read:public_key"), - ) - }, - false, - false, - true, - true, - }, - want: []string{"X: Got HTTP 404 (https://api.github.com/user/keys)"}, - }, - { - name: "missing scope", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.ScopesResponder(""), - ) - }, - false, - false, - true, - true, - }, - want: []string{ - "X: missing required scope 'repo';missing required scope 'read:org';missing required scope 'read:public_key'", - "- To request missing scopes, run: gh auth refresh -h github.com", - }, - }, - { - name: "authentication failed", - input: input{ - func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "user/keys"), - httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`), - ) - reg.Register( - httpmock.REST("GET", ""), - httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`), - ) - }, - false, - false, - true, - true, - }, - want: []string{ - "X: authentication failed", - "- The github.com token in ~/.config/gh/hosts.yml is no longer valid.", - "- To re-authenticate, run: gh auth login -h github.com", - "- To forget about this host, run: gh auth logout -h github.com", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - tt.input.httpStubs(reg) - - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - io.SetStderrTTY(true) - - opts := ListOptions{ + name: "list tty", + opts: ListOptions{ HTTPClient: func() (*http.Client, error) { - if tt.input.httpClientError { - return nil, errors.New("HttpClient error") - } + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) return &http.Client{Transport: reg}, nil }, - IO: io, - Config: func() (config.Config, error) { - if tt.input.configError { - return nil, errors.New("Config error") - } - cfg := config.NewBlankConfig() - if tt.input.hasOauthToken { - err := cfg.Set("github.com", "oauth_token", "abc123") - if err != nil { - return nil, err - } - } - return cfg, nil + }, + isTTY: true, + wantStdout: heredoc.Doc(` + Mac ssh-rsa AAAABbBB123 1d + hubot@Windows ssh-rsa EEEEEEEK247 1d + `), + wantStderr: "", + }, + { + name: "list non-tty", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + return &http.Client{Transport: reg}, nil }, - } + }, + isTTY: false, + wantStdout: heredoc.Doc(` + Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 + hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 + `), + wantStderr: "", + }, + { + name: "no keys", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[]`), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: "", + wantStderr: "No SSH keys present in GitHub account.\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + opts := tt.opts + opts.IO = io err := listRun(&opts) - if err != nil && !tt.input.wantErr { + if (err != nil) != tt.wantErr { t.Errorf("linRun() return error: %v", err) + return } - if !reflect.DeepEqual(opts.ListMsg, tt.want) { - t.Errorf("linRun() = want %v, got %v", tt.want, opts.ListMsg) + + if stdout.String() != tt.wantStdout { + t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String()) + } + if stderr.String() != tt.wantStderr { + t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String()) } }) } From b9b1079493221bb419bcd0c705ecf4b6e1bb18cc Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 7 Jan 2021 15:19:27 -0800 Subject: [PATCH 128/129] Display reviews when viewing pull requests --- api/queries_comments.go | 48 +++ api/queries_pr.go | 132 +------- api/queries_pr_review.go | 135 ++++++++ api/reaction_groups.go | 9 + pkg/cmd/issue/view/view.go | 36 +- pkg/cmd/issue/view/view_test.go | 12 +- pkg/cmd/pr/review/review.go | 6 +- pkg/cmd/pr/shared/comments.go | 128 +++++-- .../fixtures/prViewPreviewFullComments.json | 8 +- .../fixtures/prViewPreviewManyReviews.json | 67 ++++ .../view/fixtures/prViewPreviewNoReviews.json | 1 + .../view/fixtures/prViewPreviewReviews.json | 318 ++++++++++++++++++ .../fixtures/prViewPreviewSingleComment.json | 2 +- .../prViewPreviewWithMetadataByNumber.json | 22 -- .../prViewPreviewWithReviewersByNumber.json | 58 ---- pkg/cmd/pr/view/view.go | 105 ++++-- pkg/cmd/pr/view/view_test.go | 266 ++++++++++----- 17 files changed, 994 insertions(+), 359 deletions(-) create mode 100644 api/queries_pr_review.go create mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json create mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json create mode 100644 pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json diff --git a/api/queries_comments.go b/api/queries_comments.go index 17a7a9f44..16eba6152 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -132,3 +132,51 @@ 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 + ` + reactionGroupsFragment() + ` + } + totalCount + }` +} + +func (c Comment) AuthorLogin() string { + return c.Author.Login +} + +func (c Comment) Association() string { + return c.AuthorAssociation +} + +func (c Comment) Content() string { + return c.Body +} + +func (c Comment) Created() time.Time { + return c.CreatedAt +} + +func (c Comment) IsEdited() bool { + return c.IncludesCreatedEdit +} + +func (c Comment) Reactions() ReactionGroups { + return c.ReactionGroups +} + +func (c Comment) Status() string { + return "" +} + +func (c Comment) Link() string { + return "" +} diff --git a/api/queries_pr.go b/api/queries_pr.go index ad4f23644..7e2f05140 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -15,19 +15,6 @@ import ( "github.com/shurcooL/githubv4" ) -type PullRequestReviewState int - -const ( - ReviewApprove PullRequestReviewState = iota - ReviewRequestChanges - ReviewComment -) - -type PullRequestReviewInput struct { - Body string - State PullRequestReviewState -} - type PullRequestsPayload struct { ViewerCreated PullRequestAndTotalCount ReviewRequested PullRequestAndTotalCount @@ -103,14 +90,6 @@ type PullRequest struct { } TotalCount int } - Reviews struct { - Nodes []struct { - Author struct { - Login string - } - State string - } - } Assignees struct { Nodes []struct { Login string @@ -139,6 +118,7 @@ type PullRequest struct { } Comments Comments ReactionGroups ReactionGroups + Reviews PullRequestReviews } type NotFoundError struct { @@ -220,6 +200,18 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { return } +func (pr *PullRequest) DisplayableReviews() PullRequestReviews { + published := []PullRequestReview{} + for _, prr := range pr.Reviews.Nodes { + //Dont display pending reviews + //Dont display commenting reviews without top level comment body + if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") { + published = append(published, prr) + } + } + return PullRequestReviews{Nodes: published, TotalCount: len(published)} +} + func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) { url := fmt.Sprintf("%srepos/%s/pulls/%d", ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber) @@ -570,15 +562,6 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu } totalCount } - reviews(last: 100) { - nodes { - author { - login - } - state - } - totalCount - } assignees(first: 100) { nodes { login @@ -605,30 +588,8 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu milestone{ title } - comments(last: 1) { - nodes { - author { - login - } - authorAssociation - body - createdAt - includesCreatedEdit - reactionGroups { - content - users { - totalCount - } - } - } - totalCount - } - reactionGroups { - content - users { - totalCount - } - } + ` + commentsFragment() + ` + ` + reactionGroupsFragment() + ` } } }` @@ -703,15 +664,6 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea } totalCount } - reviews(last: 100) { - nodes { - author { - login - } - state - } - totalCount - } assignees(first: 100) { nodes { login @@ -738,30 +690,8 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea milestone{ title } - comments(last: 1) { - nodes { - author { - login - } - authorAssociation - body - createdAt - includesCreatedEdit - reactionGroups { - content - users { - totalCount - } - } - } - totalCount - } - reactionGroups { - content - users { - totalCount - } - } + ` + commentsFragment() + ` + ` + reactionGroupsFragment() + ` } } } @@ -906,34 +836,6 @@ func isBlank(v interface{}) bool { } } -func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { - var mutation struct { - AddPullRequestReview struct { - ClientMutationID string - } `graphql:"addPullRequestReview(input:$input)"` - } - - state := githubv4.PullRequestReviewEventComment - switch input.State { - case ReviewApprove: - state = githubv4.PullRequestReviewEventApprove - case ReviewRequestChanges: - state = githubv4.PullRequestReviewEventRequestChanges - } - - body := githubv4.String(input.Body) - variables := map[string]interface{}{ - "input": githubv4.AddPullRequestReviewInput{ - PullRequestID: pr.ID, - Event: &state, - Body: &body, - }, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables) -} - func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) { type prBlock struct { Edges []struct { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go new file mode 100644 index 000000000..7378db111 --- /dev/null +++ b/api/queries_pr_review.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" +) + +type PullRequestReviewState int + +const ( + ReviewApprove PullRequestReviewState = iota + ReviewRequestChanges + ReviewComment +) + +type PullRequestReviewInput struct { + Body string + State PullRequestReviewState +} + +type PullRequestReviews struct { + Nodes []PullRequestReview + PageInfo PageInfo + TotalCount int +} + +type PullRequestReview struct { + Author Author + AuthorAssociation string + Body string + CreatedAt time.Time + IncludesCreatedEdit bool + ReactionGroups ReactionGroups + State string + URL string +} + +func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { + var mutation struct { + AddPullRequestReview struct { + ClientMutationID string + } `graphql:"addPullRequestReview(input:$input)"` + } + + state := githubv4.PullRequestReviewEventComment + switch input.State { + case ReviewApprove: + state = githubv4.PullRequestReviewEventApprove + case ReviewRequestChanges: + state = githubv4.PullRequestReviewEventRequestChanges + } + + body := githubv4.String(input.Body) + variables := map[string]interface{}{ + "input": githubv4.AddPullRequestReviewInput{ + PullRequestID: pr.ID, + Event: &state, + Body: &body, + }, + } + + gql := graphQLClient(client.http, repo.RepoHost()) + 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 +} + +func (prr PullRequestReview) Association() string { + return prr.AuthorAssociation +} + +func (prr PullRequestReview) Content() string { + return prr.Body +} + +func (prr PullRequestReview) Created() time.Time { + return prr.CreatedAt +} + +func (prr PullRequestReview) IsEdited() bool { + return prr.IncludesCreatedEdit +} + +func (prr PullRequestReview) Reactions() ReactionGroups { + return prr.ReactionGroups +} + +func (prr PullRequestReview) Status() string { + return prr.State +} + +func (prr PullRequestReview) Link() string { + return prr.URL +} diff --git a/api/reaction_groups.go b/api/reaction_groups.go index 381504dd7..849fe4b36 100644 --- a/api/reaction_groups.go +++ b/api/reaction_groups.go @@ -29,3 +29,12 @@ var reactionEmoji = map[string]string{ "ROCKET": "\U0001f680", "EYES": "\U0001f440", } + +func reactionGroupsFragment() string { + return `reactionGroups { + content + users { + totalCount + } + }` +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 811f88088..24867b32b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -46,9 +46,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman Display the title, body, and other information about an issue. With '--web', open the issue in a web browser instead. - `), - Example: heredoc.Doc(` - `), + `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override @@ -110,11 +108,11 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() if opts.IO.IsStdoutTTY() { - return printHumanIssuePreview(opts.IO, issue) + return printHumanIssuePreview(opts, issue) } if opts.Comments { - fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments)) + fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{})) return nil } @@ -141,11 +139,11 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { return nil } -func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { - out := io.Out +func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error { + out := opts.IO.Out now := time.Now() ago := now.Sub(issue.CreatedAt) - cs := io.ColorScheme() + cs := opts.IO.ColorScheme() // Header (Title and State) fmt.Fprintln(out, cs.Bold(issue.Title)) @@ -182,21 +180,23 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { } // Body - fmt.Fprintln(out) + var md string + var err error if issue.Body == "" { - issue.Body = "_No description provided_" + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + } else { + style := markdown.GetStyle(opts.IO.TerminalTheme()) + md, err = markdown.Render(issue.Body, style, "") + if err != nil { + return err + } } - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(issue.Body, style, "") - if err != nil { - return err - } - fmt.Fprint(out, md) - fmt.Fprintln(out) + fmt.Fprintf(out, "\n%s\n", md) // Comments if issue.Comments.TotalCount > 0 { - comments, err := prShared.CommentList(io, issue.Comments) + preview := !opts.Comments + comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview) if err != nil { return err } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 82890eac9..a1664aa07 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -368,7 +368,7 @@ func TestIssueView_tty_Comments(t *testing.T) { `some title`, `some body`, `———————— Not showing 4 comments ————————`, - `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, `Comment 5`, `Use --comments to view the full conversation`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, @@ -383,16 +383,16 @@ func TestIssueView_tty_Comments(t *testing.T) { expectedOutputs: []string{ `some title`, `some body`, - `monalisa • Jan 1, 2020 • edited`, + `monalisa • Jan 1, 2020 • Edited`, `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, `Comment 1`, - `johnnytest \(contributor\) • Jan 1, 2020`, + `johnnytest \(Contributor\) • Jan 1, 2020`, `Comment 2`, - `elvisp \(member\) • Jan 1, 2020`, + `elvisp \(Member\) • Jan 1, 2020`, `Comment 3`, - `loislane \(owner\) • Jan 1, 2020`, + `loislane \(Owner\) • Jan 1, 2020`, `Comment 4`, - `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, `Comment 5`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 4bb3317cf..4fb23ec97 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -60,13 +60,13 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co Example: heredoc.Doc(` # approve the pull request of the current branch $ gh pr review --approve - + # leave a review comment for the current branch $ gh pr review --comment -b "interesting" - + # add a review for a specific pull request $ gh pr review 123 - + # request changes on a specific pull request $ gh pr review 123 -r -b "needs more ASCII art" `), diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index f0d820c1f..9f27416a2 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -2,6 +2,7 @@ package shared import ( "fmt" + "sort" "strings" "time" @@ -11,37 +12,55 @@ import ( "github.com/cli/cli/utils" ) -func RawCommentList(comments api.Comments) string { +type Comment interface { + AuthorLogin() string + Association() string + Content() string + Created() time.Time + IsEdited() bool + Link() string + Reactions() api.ReactionGroups + Status() string +} + +func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string { + sortedComments := sortComments(comments, reviews) var b strings.Builder - for _, comment := range comments.Nodes { + for _, comment := range sortedComments { fmt.Fprint(&b, formatRawComment(comment)) } return b.String() } -func formatRawComment(comment api.Comment) string { +func formatRawComment(comment Comment) string { var b strings.Builder - fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login) - fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation)) - fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit) + fmt.Fprintf(&b, "author:\t%s\n", comment.AuthorLogin()) + fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.Association())) + fmt.Fprintf(&b, "edited:\t%t\n", comment.IsEdited()) + fmt.Fprintf(&b, "status:\t%s\n", formatRawCommentStatus(comment.Status())) fmt.Fprintln(&b, "--") - fmt.Fprintln(&b, comment.Body) + fmt.Fprintln(&b, comment.Content()) fmt.Fprintln(&b, "--") return b.String() } -func CommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) { +func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.PullRequestReviews, preview bool) (string, error) { + sortedComments := sortComments(comments, reviews) + if preview && len(sortedComments) > 0 { + sortedComments = sortedComments[len(sortedComments)-1:] + } var b strings.Builder cs := io.ColorScheme() - retrievedCount := len(comments.Nodes) - hiddenCount := comments.TotalCount - retrievedCount + totalCount := comments.TotalCount + reviews.TotalCount + retrievedCount := len(sortedComments) + hiddenCount := totalCount - retrievedCount if hiddenCount > 0 { fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) fmt.Fprintf(&b, "\n\n\n") } - for i, comment := range comments.Nodes { + for i, comment := range sortedComments { last := i+1 == retrievedCount cmt, err := formatComment(io, comment, last) if err != nil { @@ -61,18 +80,21 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) return b.String(), nil } -func formatComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) { +func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (string, error) { var b strings.Builder cs := io.ColorScheme() // Header - fmt.Fprint(&b, cs.Bold(comment.Author.Login)) - if comment.AuthorAssociation != "NONE" { - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation)))) + fmt.Fprint(&b, cs.Bold(comment.AuthorLogin())) + if comment.Status() != "" { + fmt.Fprint(&b, formatCommentStatus(cs, comment.Status())) } - fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt)))) - if comment.IncludesCreatedEdit { - fmt.Fprint(&b, cs.Bold(" • edited")) + if comment.Association() != "NONE" { + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association()))))) + } + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created())))) + if comment.IsEdited() { + fmt.Fprint(&b, cs.Bold(" • Edited")) } if newest { fmt.Fprint(&b, cs.Bold(" • ")) @@ -81,20 +103,82 @@ func formatComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (s fmt.Fprintln(&b) // Reactions - if reactions := ReactionGroupList(comment.ReactionGroups); reactions != "" { + if reactions := ReactionGroupList(comment.Reactions()); reactions != "" { fmt.Fprint(&b, reactions) fmt.Fprintln(&b) } // Body - if comment.Body != "" { + var md string + var err error + if comment.Content() == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided")) + } else { style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(comment.Body, style, "") + md, err = markdown.Render(comment.Content(), style, "") if err != nil { return "", err } - fmt.Fprint(&b, md) + } + fmt.Fprint(&b, md) + + // Footer + if comment.Link() != "" { + fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link()) } return b.String(), nil } + +func sortComments(cs api.Comments, rs api.PullRequestReviews) []Comment { + comments := cs.Nodes + reviews := rs.Nodes + var sorted []Comment = make([]Comment, len(comments)+len(reviews)) + + var i int + for _, c := range comments { + sorted[i] = c + i++ + } + for _, r := range reviews { + sorted[i] = r + i++ + } + + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Created().Before(sorted[j].Created()) + }) + + return sorted +} + +const ( + approvedStatus = "APPROVED" + changesRequestedStatus = "CHANGES_REQUESTED" + commentedStatus = "COMMENTED" + dismissedStatus = "DISMISSED" +) + +func formatCommentStatus(cs *iostreams.ColorScheme, status string) string { + switch status { + case approvedStatus: + return fmt.Sprintf(" %s", cs.Green("approved")) + case changesRequestedStatus: + return fmt.Sprintf(" %s", cs.Red("requested changes")) + case commentedStatus, dismissedStatus: + return fmt.Sprintf(" %s", strings.ToLower(status)) + } + + return "" +} + +func formatRawCommentStatus(status string) string { + if status == approvedStatus || + status == changesRequestedStatus || + status == commentedStatus || + status == dismissedStatus { + return strings.ReplaceAll(strings.ToLower(status), "_", " ") + } + + return "none" +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json index cadbf294b..1ee02364a 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json @@ -69,7 +69,7 @@ }, "authorAssociation": "CONTRIBUTOR", "body": "Comment 2", - "createdAt": "2020-01-01T12:00:00Z", + "createdAt": "2020-01-03T12:00:00Z", "includesCreatedEdit": false, "reactionGroups": [ { @@ -128,7 +128,7 @@ }, "authorAssociation": "MEMBER", "body": "Comment 3", - "createdAt": "2020-01-01T12:00:00Z", + "createdAt": "2020-01-05T12:00:00Z", "includesCreatedEdit": false, "reactionGroups": [ { @@ -187,7 +187,7 @@ }, "authorAssociation": "OWNER", "body": "Comment 4", - "createdAt": "2020-01-01T12:00:00Z", + "createdAt": "2020-01-07T12:00:00Z", "includesCreatedEdit": false, "reactionGroups": [ { @@ -246,7 +246,7 @@ }, "authorAssociation": "COLLABORATOR", "body": "Comment 5", - "createdAt": "2020-01-01T12:00:00Z", + "createdAt": "2020-01-09T12:00:00Z", "includesCreatedEdit": false, "reactionGroups": [ { diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json new file mode 100644 index 000000000..5645f7df4 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json @@ -0,0 +1,67 @@ +{ + "data": { + "repository": { + "pullRequest": { + "reviews": { + "nodes": [ + { + "author": { + "login": "123" + }, + "state": "COMMENTED" + }, + { + "author": { + "login": "def" + }, + "state": "CHANGES_REQUESTED" + }, + { + "author": { + "login": "abc" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "DEF" + }, + "state": "COMMENTED" + }, + { + "author": { + "login": "xyz" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "hubot" + }, + "state": "CHANGES_REQUESTED" + }, + { + "author": { + "login": "hubot" + }, + "state": "DISMISSED" + }, + { + "author": { + "login": "monalisa" + }, + "state": "PENDING" + } + ], + "totalCount": 9 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json new file mode 100644 index 000000000..92e1a5a75 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json @@ -0,0 +1 @@ +{ "data": { "repository": { "pullRequest": { "reviews": { } } } } } diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json new file mode 100644 index 000000000..393003fd9 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json @@ -0,0 +1,318 @@ +{ + "data": { + "repository": { + "pullRequest": { + "reviews": { + "nodes": [ + { + "author": { + "login": "sam" + }, + "authorAssociation": "NONE", + "body": "Review 1", + "createdAt": "2020-01-02T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 1 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 1 + } + } + ], + "state": "COMMENTED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-1" + }, + { + "author": { + "login": "matt" + }, + "authorAssociation": "OWNER", + "body": "Review 2", + "createdAt": "2020-01-04T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 1 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "CHANGES_REQUESTED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-2" + }, + { + "author": { + "login": "leah" + }, + "authorAssociation": "MEMBER", + "body": "Review 3", + "createdAt": "2020-01-06T12:00:00Z", + "includesCreatedEdit": true, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "APPROVED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-3" + }, + { + "author": { + "login": "louise" + }, + "authorAssociation": "NONE", + "body": "Review 4", + "createdAt": "2020-01-08T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "DISMISSED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-4" + }, + { + "author": { + "login": "david" + }, + "authorAssociation": "NONE", + "body": "Review 5", + "createdAt": "2020-01-10T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "PENDING", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-5" + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json index c6ddc4321..71d58fe83 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json @@ -93,7 +93,7 @@ }, "authorAssociation": "COLLABORATOR", "body": "Comment 5", - "createdAt": "2020-01-01T12:00:00Z", + "createdAt": "2020-01-09T12:00:00Z", "includesCreatedEdit": false, "reactionGroups": [ { diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json index 0ca6124e6..c6f801477 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json @@ -21,28 +21,6 @@ ], "totalcount": 1 }, - "reviews": { - "nodes": [ - { - "author": { - "login": "3" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "2" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "1" - }, - "state": "CHANGES_REQUESTED" - } - ] - }, "assignees": { "nodes": [ { diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json index 4d2bd57af..6ff594fec 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json @@ -33,64 +33,6 @@ ], "totalcount": 1 }, - "reviews": { - "nodes": [ - { - "author": { - "login": "123" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "def" - }, - "state": "CHANGES_REQUESTED" - }, - { - "author": { - "login": "abc" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "DEF" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "xyz" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "hubot" - }, - "state": "CHANGES_REQUESTED" - }, - { - "author": { - "login": "hubot" - }, - "state": "DISMISSED" - }, - { - "author": { - "login": "monalisa" - }, - "state": "PENDING" - } - ] - }, "assignees": { "nodes": [], "totalcount": 0 diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index ca3f0580d..b2f84ff78 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -6,6 +6,7 @@ import ( "net/http" "sort" "strings" + "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" @@ -80,13 +81,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } func viewRun(opts *ViewOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + opts.IO.StartProgressIndicator() + pr, err := retrievePullRequest(opts) + opts.IO.StopProgressIndicator() if err != nil { return err } @@ -101,16 +98,6 @@ func viewRun(opts *ViewOptions) error { return utils.OpenInBrowser(openURL) } - if opts.Comments { - opts.IO.StartProgressIndicator() - comments, err := api.CommentsForPullRequest(apiClient, repo, pr) - opts.IO.StopProgressIndicator() - if err != nil { - return err - } - pr.Comments = *comments - } - opts.IO.DetectTerminalTheme() err = opts.IO.StartPager() @@ -120,11 +107,11 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() if connectedToTerminal { - return printHumanPrPreview(opts.IO, pr) + return printHumanPrPreview(opts, pr) } if opts.Comments { - fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments)) + fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.Reviews)) return nil } @@ -157,9 +144,9 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { return nil } -func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { - out := io.Out - cs := io.ColorScheme() +func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() // Header (Title and State) fmt.Fprintln(out, cs.Bold(pr.Title)) @@ -201,21 +188,23 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { } // Body - fmt.Fprintln(out) + var md string + var err error if pr.Body == "" { - pr.Body = "_No description provided_" + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + } else { + style := markdown.GetStyle(opts.IO.TerminalTheme()) + md, err = markdown.Render(pr.Body, style, "") + if err != nil { + return err + } } - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(pr.Body, style, "") - if err != nil { - return err - } - fmt.Fprint(out, md) - fmt.Fprintln(out) + fmt.Fprintf(out, "\n%s\n", md) - // Comments - if pr.Comments.TotalCount > 0 { - comments, err := shared.CommentList(io, pr.Comments) + // Reviews and Comments + if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 { + preview := !opts.Comments + comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview) if err != nil { return err } @@ -405,3 +394,51 @@ func prStateWithDraft(pr *api.PullRequest) string { return pr.State } + +func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) { + httpClient, err := opts.HttpClient() + if err != nil { + return nil, err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return nil, err + } + + if opts.BrowserMode { + return pr, nil + } + + var errp, errc error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + var reviews *api.PullRequestReviews + reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr) + pr.Reviews = *reviews + }() + + if opts.Comments { + wg.Add(1) + go func() { + defer wg.Done() + var comments *api.Comments + comments, errc = api.CommentsForPullRequest(apiClient, repo, pr) + pr.Comments = *comments + }() + } + + wg.Wait() + + if errp != nil { + err = errp + } + if errc != nil { + err = errc + } + return pr, err +} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index e61ccd614..ff2f255cf 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -168,13 +168,16 @@ func TestPRView_Preview_nontty(t *testing.T) { tests := map[string]struct { branch string args string - fixture string + fixtures map[string]string expectedOutputs []string }{ "Open PR without metadata": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreview.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreview.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tOPEN\n`, @@ -190,12 +193,15 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with metadata by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, - `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`, + `reviewers:\t1 \(Requested\)\n`, `assignees:\tmarseilles, monaco\n`, `labels:\tone, two, three, four, five\n`, `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, @@ -204,9 +210,12 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with reviewers by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tOPEN\n`, @@ -220,14 +229,18 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + 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`, @@ -235,14 +248,18 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView.json", + 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`, @@ -250,23 +267,30 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView_EmptyBody.json", + 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`, }, }, "Closed PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewClosedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `state:\tCLOSED\n`, `author:\tnobody\n`, @@ -279,9 +303,12 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Merged PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewMergedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `state:\tMERGED\n`, `author:\tnobody\n`, @@ -294,30 +321,38 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Draft PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewDraftState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tDRAFT\n`, `author:\tnobody\n`, `labels:`, `assignees:`, + `reviewers:`, `projects:`, `milestone:`, `\*\*blueberries taste good\*\*`, }, }, "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + 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:`, `\*\*blueberries taste good\*\*`, @@ -329,7 +364,10 @@ func TestPRView_Preview_nontty(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } output, err := runCommand(http, tc.branch, false, tc.args) if err != nil { @@ -347,13 +385,16 @@ func TestPRView_Preview(t *testing.T) { tests := map[string]struct { branch string args string - fixture string + fixtures map[string]string expectedOutputs []string }{ "Open PR without metadata": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreview.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreview.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Open.*nobody wants to merge 12 commits into master from blueberries`, @@ -362,13 +403,16 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR with metadata by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Open.*nobody wants to merge 12 commits into master from blueberries`, - `Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`, + `Reviewers:.*1 \(.*Requested.*\)\n`, `Assignees:.*marseilles, monaco\n`, `Labels:.*one, two, three, four, five\n`, `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, @@ -378,9 +422,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR with reviewers by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, @@ -389,9 +436,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + 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`, @@ -404,9 +454,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView.json", + 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`, @@ -415,9 +468,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView_EmptyBody.json", + 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`, @@ -425,9 +481,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Closed PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewClosedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Closed.*nobody wants to merge 12 commits into master from blueberries`, @@ -436,9 +495,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Merged PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewMergedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Merged.*nobody wants to merge 12 commits into master from blueberries`, @@ -447,9 +509,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Draft PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewDraftState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Draft.*nobody wants to merge 12 commits into master from blueberries`, @@ -458,9 +523,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + 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`, @@ -474,7 +542,10 @@ func TestPRView_Preview(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } output, err := runCommand(http, tc.branch, true, tc.args) if err != nil { @@ -731,14 +802,15 @@ func TestPRView_tty_Comments(t *testing.T) { branch: "master", cli: "123", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", }, expectedOutputs: []string{ `some title`, `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`, `some body`, - `———————— Not showing 4 comments ————————`, - `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `———————— Not showing 8 comments ————————`, + `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`, `4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680}`, `Comment 5`, `Use --comments to view the full conversation`, @@ -750,21 +822,36 @@ func TestPRView_tty_Comments(t *testing.T) { cli: "123 --comments", fixtures: map[string]string{ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", }, expectedOutputs: []string{ `some title`, `some body`, - `monalisa • Jan 1, 2020 • edited`, + `monalisa • Jan 1, 2020 • Edited`, `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, `Comment 1`, - `johnnytest \(contributor\) • Jan 1, 2020`, + `sam commented • Jan 2, 2020`, + `1 \x{1f44e} • 1 \x{1f44d}`, + `Review 1`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-1`, + `johnnytest \(Contributor\) • Jan 3, 2020`, `Comment 2`, - `elvisp \(member\) • Jan 1, 2020`, + `matt requested changes \(Owner\) • Jan 4, 2020`, + `1 \x{1f615} • 1 \x{1f440}`, + `Review 2`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-2`, + `elvisp \(Member\) • Jan 5, 2020`, `Comment 3`, - `loislane \(owner\) • Jan 1, 2020`, + `leah approved \(Member\) • Jan 6, 2020 • Edited`, + `Review 3`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-3`, + `loislane \(Owner\) • Jan 7, 2020`, `Comment 4`, - `marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`, + `louise dismissed • Jan 8, 2020`, + `Review 4`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-4`, + `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`, `Comment 5`, `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, @@ -808,7 +895,8 @@ func TestPRView_nontty_Comments(t *testing.T) { branch: "master", cli: "123", fixtures: map[string]string{ - "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", }, expectedOutputs: []string{ `title:\tsome title`, @@ -823,28 +911,54 @@ func TestPRView_nontty_Comments(t *testing.T) { cli: "123 --comments", fixtures: map[string]string{ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", }, expectedOutputs: []string{ `author:\tmonalisa`, - `association:\t`, + `association:\tnone`, `edited:\ttrue`, + `status:\tnone`, `Comment 1`, + `author:\tsam`, + `association:\tnone`, + `edited:\tfalse`, + `status:\tcommented`, + `Review 1`, `author:\tjohnnytest`, `association:\tcontributor`, `edited:\tfalse`, + `status:\tnone`, `Comment 2`, + `author:\tmatt`, + `association:\towner`, + `edited:\tfalse`, + `status:\tchanges requested`, + `Review 2`, `author:\telvisp`, `association:\tmember`, `edited:\tfalse`, + `status:\tnone`, `Comment 3`, + `author:\tleah`, + `association:\tmember`, + `edited:\ttrue`, + `status:\tapproved`, + `Review 3`, `author:\tloislane`, `association:\towner`, `edited:\tfalse`, + `status:\tnone`, `Comment 4`, + `author:\tlouise`, + `association:\tnone`, + `edited:\tfalse`, + `status:\tdismissed`, + `Review 4`, `author:\tmarseilles`, `association:\tcollaborator`, `edited:\tfalse`, + `status:\tnone`, `Comment 5`, }, }, From c9f79271b17d3cb727b3586d4e50fc9a5fad8fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Heinrichs?= Date: Wed, 20 Jan 2021 23:51:27 +0100 Subject: [PATCH 129/129] Add --maintainer-edit flag (#2250) * Add --maintainer-edit flag Closes #2213 while retaining backwards compatibility. * Fix linting * Adjust documentation and validation * Negate logic and fix build errors * rename to no-maintainer-edit * test * use a positive option instead of negative Co-authored-by: vilmibm --- api/queries_pr.go | 2 +- pkg/cmd/pr/create/create.go | 23 ++++++++++---- pkg/cmd/pr/create/create_test.go | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 7e2f05140..23896ce28 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -751,7 +751,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } for key, val := range params { switch key { - case "title", "body", "draft", "baseRefName", "headRefName": + case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify": inputParams[key] = val } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index db623acff..009c567a7 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -53,6 +53,8 @@ type CreateOptions struct { Labels []string Projects []string Milestone string + + MaintainerCanModify bool } type CreateContext struct { @@ -91,6 +93,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co A prompt will also ask for the title and the body of the pull request. Use '--title' and '--body' to skip this, or use '--fill' to autofill these values from git commits. + + By default users with write access to the base respository can add new commits to your branch. + If undesired, you may disable access of maintainers by using '--no-maintainer-edit' + You can always change this setting later via the web interface. `), Example: heredoc.Doc(` $ gh pr create --title "The bug is fixed" --body "Everything works again" @@ -103,6 +109,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.TitleProvided = cmd.Flags().Changed("title") opts.BodyProvided = cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") + noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit") + opts.MaintainerCanModify = !noMaintainerEdit if !opts.IO.CanPrompt() && opts.RecoverFile != "" { return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")} @@ -118,6 +126,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if len(opts.Reviewers) > 0 && opts.WebMode { return errors.New("the --reviewer flag is not supported with --web") } + if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode { + return errors.New("the --no-maintainer-edit flag is not supported with --web") + } if runF != nil { return runF(opts) @@ -139,6 +150,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") + fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request") fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") return cmd @@ -561,11 +573,12 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS client := ctx.Client params := map[string]interface{}{ - "title": state.Title, - "body": state.Body, - "draft": state.Draft, - "baseRefName": ctx.BaseBranch, - "headRefName": ctx.HeadBranchLabel, + "title": state.Title, + "body": state.Body, + "draft": state.Draft, + "baseRefName": ctx.BaseBranch, + "headRefName": ctx.HeadBranchLabel, + "maintainerCanModify": opts.MaintainerCanModify, } if params["title"] == "" { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 939e4116b..6809a2040 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -304,6 +304,57 @@ func TestPRCreate(t *testing.T) { assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr()) } +func TestPRCreate_NoMaintainerModify(t *testing.T) { + // TODO update this copypasta + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "master") + http.StubRepoResponse("OWNER", "REPO") + 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" : [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(input map[string]interface{}) { + assert.Equal(t, false, input["maintainerCanModify"].(bool)) + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp (determineTrackingBranch) + cs.Stub("") // git show-ref --verify (determineTrackingBranch) + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + cs.Stub("") // git push + + ask, cleanupAsk := prompt.InitAskStubber() + defer cleanupAsk() + ask.StubOne(0) + + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body" --no-maintainer-edit`) + require.NoError(t, err) + + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) + assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr()) +} + func TestPRCreate_createFork(t *testing.T) { http := initFakeHTTP() defer http.Verify(t)