diff --git a/go.mod b/go.mod index e704f3b68..9c73862af 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( golang.org/x/term v0.17.0 golang.org/x/text v0.14.0 google.golang.org/grpc v1.61.0 - google.golang.org/protobuf v1.32.0 + google.golang.org/protobuf v1.33.0 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index b958897e5..e0d87e3e8 100644 --- a/go.sum +++ b/go.sum @@ -562,8 +562,8 @@ google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 9169e54fe..38bc81a72 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/url" "os" @@ -23,6 +24,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" "github.com/spf13/cobra" ) @@ -64,6 +66,8 @@ type CreateOptions struct { MaintainerCanModify bool Template string + + DryRun bool } type CreateContext struct { @@ -190,6 +194,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill` or `fill-first` or `--fillverbose`) when not running interactively") } + if opts.DryRun && opts.WebMode { + return cmdutil.FlagErrorf("`--dry-run` is not supported when using `--web`") + } + if runF != nil { return runF(opts) } @@ -216,6 +224,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co 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") fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") + fl.BoolVar(&opts.DryRun, "dry-run", false, "Print details instead of creating the PR. May still push git changes.") _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") @@ -292,6 +301,9 @@ func createRun(opts *CreateOptions) (err error) { if state.Draft { message = "\nCreating draft pull request for %s into %s in %s\n\n" } + if opts.DryRun { + message = "\nDry Running pull request for %s into %s in %s\n\n" + } cs := opts.IO.ColorScheme() @@ -360,7 +372,7 @@ func createRun(opts *CreateOptions) (err error) { return } - allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) + allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun allowMetadata := ctx.BaseRepo.ViewerCanTriage() action, err := shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft) if err != nil { @@ -379,7 +391,7 @@ func createRun(opts *CreateOptions) (err error) { return } - action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata(), false, state.Draft) + action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata() && !opts.DryRun, false, state.Draft) if err != nil { return } @@ -712,6 +724,14 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return err } + if opts.DryRun { + if opts.IO.IsStdoutTTY() { + return renderPullRequestTTY(opts.IO, params, &state) + } else { + return renderPullRequestPlain(opts.IO.Out, params, &state) + } + } + opts.IO.StartProgressIndicator() pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params) opts.IO.StopProgressIndicator() @@ -727,6 +747,80 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return nil } +func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *shared.IssueMetadataState) error { + fmt.Fprint(w, "Would have created a Pull Request with:\n") + fmt.Fprintf(w, "title:\t%s\n", params["title"]) + fmt.Fprintf(w, "draft:\t%t\n", params["draft"]) + fmt.Fprintf(w, "base:\t%s\n", params["baseRefName"]) + fmt.Fprintf(w, "head:\t%s\n", params["headRefName"]) + if len(state.Labels) != 0 { + fmt.Fprintf(w, "labels:\t%v\n", strings.Join(state.Labels, ", ")) + } + if len(state.Reviewers) != 0 { + fmt.Fprintf(w, "reviewers:\t%v\n", strings.Join(state.Reviewers, ", ")) + } + if len(state.Assignees) != 0 { + fmt.Fprintf(w, "assignees:\t%v\n", strings.Join(state.Assignees, ", ")) + } + if len(state.Milestones) != 0 { + fmt.Fprintf(w, "milestones:\t%v\n", strings.Join(state.Milestones, ", ")) + } + if len(state.Projects) != 0 { + fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.Projects, ", ")) + } + fmt.Fprintf(w, "maintainerCanModify:\t%t\n", params["maintainerCanModify"]) + fmt.Fprint(w, "body:\n") + if len(params["body"].(string)) != 0 { + fmt.Fprintln(w, params["body"]) + } + return nil +} + +func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error { + iofmt := io.ColorScheme() + out := io.Out + + fmt.Fprint(out, "Would have created a Pull Request with:\n") + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Title"), params["title"].(string)) + fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("Draft"), params["draft"]) + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Base"), params["baseRefName"]) + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Head"), params["headRefName"]) + if len(state.Labels) != 0 { + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Labels"), strings.Join(state.Labels, ", ")) + } + if len(state.Reviewers) != 0 { + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Reviewers"), strings.Join(state.Reviewers, ", ")) + } + if len(state.Assignees) != 0 { + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Assignees"), strings.Join(state.Assignees, ", ")) + } + if len(state.Milestones) != 0 { + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Milestones"), strings.Join(state.Milestones, ", ")) + } + if len(state.Projects) != 0 { + fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Projects"), strings.Join(state.Projects, ", ")) + } + fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("MaintainerCanModify"), params["maintainerCanModify"]) + + fmt.Fprintf(out, "%s\n", iofmt.Bold("Body:")) + // Body + var md string + var err error + if len(params["body"].(string)) == 0 { + md = fmt.Sprintf("%s\n", iofmt.Gray("No description provided")) + } else { + md, err = markdown.Render(params["body"].(string), + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(out, "%s", md) + + return nil +} + func previewPR(opts CreateOptions, openURL string) error { if opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 7d981e686..b7d0e46ae 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -194,6 +195,12 @@ func TestNewCmdCreate(t *testing.T) { cli: "--fill --fill-first", wantsErr: true, }, + { + name: "dry-run and web", + tty: false, + cli: "--web --dry-run", + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -250,16 +257,17 @@ func TestNewCmdCreate(t *testing.T) { func Test_createRun(t *testing.T) { tests := []struct { - name string - setup func(*CreateOptions, *testing.T) func() - cmdStubs func(*run.CommandStubber) - promptStubs func(*prompter.PrompterMock) - httpStubs func(*httpmock.Registry, *testing.T) - expectedOut string - expectedErrOut string - expectedBrowse string - wantErr string - tty bool + name string + setup func(*CreateOptions, *testing.T) func() + cmdStubs func(*run.CommandStubber) + promptStubs func(*prompter.PrompterMock) + httpStubs func(*httpmock.Registry, *testing.T) + expectedOutputs []string + expectedOut string + expectedErrOut string + expectedBrowse string + wantErr string + tty bool }{ { name: "nontty web", @@ -300,6 +308,228 @@ func Test_createRun(t *testing.T) { }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, + { + name: "dry-run-nontty-with-default-base", + tty: false, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.HeadBranch = "feature" + opts.DryRun = true + return func() {} + }, + expectedOutputs: []string{ + "Would have created a Pull Request with:", + `title: my title`, + `draft: false`, + `base: master`, + `head: feature`, + `maintainerCanModify: false`, + `body:`, + `my body`, + ``, + }, + expectedErrOut: "", + }, + { + name: "dry-run-nontty-with-all-opts", + tty: false, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "TITLE" + opts.Body = "BODY" + opts.BaseBranch = "trunk" + opts.HeadBranch = "feature" + opts.Assignees = []string{"monalisa"} + opts.Labels = []string{"bug", "todo"} + opts.Projects = []string{"roadmap"} + opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Milestone = "big one.oh" + opts.DryRun = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "MonaLisa", "id": "MONAID" }, + "u001": { "login": "hubot", "id": "HUBOTID" }, + "repository": { + "l000": { "name": "bug", "id": "BUGID" }, + "l001": { "name": "TODO", "id": "TODOID" } + }, + "organization": { + "t000": { "slug": "core", "id": "COREID" }, + "t001": { "slug": "robots", "id": "ROBOTID" } + } + } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockRetrieveProjects(t, reg) + }, + expectedOutputs: []string{ + "Would have created a Pull Request with:", + `title: TITLE`, + `draft: false`, + `base: trunk`, + `head: feature`, + `labels: bug, todo`, + `reviewers: hubot, monalisa, /core, /robots`, + `assignees: monalisa`, + `milestones: big one.oh`, + `projects: roadmap`, + `maintainerCanModify: false`, + `body:`, + `BODY`, + ``, + }, + expectedErrOut: "", + }, + { + name: "dry-run-tty-with-default-base", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.HeadBranch = "feature" + opts.DryRun = true + return func() {} + }, + expectedOutputs: []string{ + `Would have created a Pull Request with:`, + `Title: my title`, + `Draft: false`, + `Base: master`, + `Head: feature`, + `MaintainerCanModify: false`, + `Body:`, + ``, + ` my body `, + ``, + ``, + }, + expectedErrOut: heredoc.Doc(` + + Dry Running pull request for feature into master in OWNER/REPO + + `), + }, + { + name: "dry-run-tty-with-all-opts", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "TITLE" + opts.Body = "BODY" + opts.BaseBranch = "trunk" + opts.HeadBranch = "feature" + opts.Assignees = []string{"monalisa"} + opts.Labels = []string{"bug", "todo"} + opts.Projects = []string{"roadmap"} + opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Milestone = "big one.oh" + opts.DryRun = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "MonaLisa", "id": "MONAID" }, + "u001": { "login": "hubot", "id": "HUBOTID" }, + "repository": { + "l000": { "name": "bug", "id": "BUGID" }, + "l001": { "name": "TODO", "id": "TODOID" } + }, + "organization": { + "t000": { "slug": "core", "id": "COREID" }, + "t001": { "slug": "robots", "id": "ROBOTID" } + } + } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockRetrieveProjects(t, reg) + }, + expectedOutputs: []string{ + `Would have created a Pull Request with:`, + `Title: TITLE`, + `Draft: false`, + `Base: trunk`, + `Head: feature`, + `Labels: bug, todo`, + `Reviewers: hubot, monalisa, /core, /robots`, + `Assignees: monalisa`, + `Milestones: big one.oh`, + `Projects: roadmap`, + `MaintainerCanModify: false`, + `Body:`, + ``, + ` BODY `, + ``, + ``, + }, + expectedErrOut: heredoc.Doc(` + + Dry Running pull request for feature into trunk in OWNER/REPO + + `), + }, + { + name: "dry-run-tty-with-empty-body", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "TITLE" + opts.Body = "" + opts.HeadBranch = "feature" + opts.DryRun = true + return func() {} + }, + expectedOut: heredoc.Doc(` + Would have created a Pull Request with: + Title: TITLE + Draft: false + Base: master + Head: feature + MaintainerCanModify: false + Body: + No description provided + `), + expectedErrOut: heredoc.Doc(` + + Dry Running pull request for feature into master in OWNER/REPO + + `), + }, { name: "survey", tty: true, @@ -1219,7 +1449,12 @@ func Test_createRun(t *testing.T) { assert.EqualError(t, err, tt.wantErr) } else { assert.NoError(t, err) - assert.Equal(t, tt.expectedOut, output.String()) + if tt.expectedOut != "" { + assert.Equal(t, tt.expectedOut, output.String()) + } + if len(tt.expectedOutputs) > 0 { + assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n")) + } assert.Equal(t, tt.expectedErrOut, output.Stderr()) assert.Equal(t, tt.expectedBrowse, output.BrowsedURL) }