package create import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCmdCreate(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "my-body.md") err := os.WriteFile(tmpFile, []byte("a body from file"), 0600) require.NoError(t, err) tests := []struct { name string tty bool stdin string cli string config string wantsErr bool wantsOpts CreateOptions }{ { name: "empty non-tty", tty: false, cli: "", wantsErr: true, }, { name: "only title non-tty", tty: false, cli: "--title mytitle", wantsErr: true, }, { name: "minimum non-tty", tty: false, cli: "--title mytitle --body ''", wantsErr: false, wantsOpts: CreateOptions{ Title: "mytitle", TitleProvided: true, Body: "", BodyProvided: true, Autofill: false, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, }, }, { name: "empty tty", tty: true, cli: "", wantsErr: false, wantsOpts: CreateOptions{ Title: "", TitleProvided: false, Body: "", BodyProvided: false, Autofill: false, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, }, }, { name: "body from stdin", tty: false, stdin: "this is on standard input", cli: "-t mytitle -F -", wantsErr: false, wantsOpts: CreateOptions{ Title: "mytitle", TitleProvided: true, Body: "this is on standard input", BodyProvided: true, Autofill: false, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, }, }, { name: "body from file", tty: false, cli: fmt.Sprintf("-t mytitle -F '%s'", tmpFile), wantsErr: false, wantsOpts: CreateOptions{ Title: "mytitle", TitleProvided: true, Body: "a body from file", BodyProvided: true, Autofill: false, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, }, }, { name: "template from file name tty", tty: true, cli: "-t mytitle --template bug_fix.md", wantsErr: false, wantsOpts: CreateOptions{ Title: "mytitle", TitleProvided: true, Body: "", BodyProvided: false, Autofill: false, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, Template: "bug_fix.md", }, }, { name: "template from file name non-tty", tty: false, cli: "-t mytitle --template bug_fix.md", wantsErr: true, }, { name: "template and body", tty: false, cli: `-t mytitle --template bug_fix.md --body "pr body"`, wantsErr: true, }, { name: "template and body file", tty: false, cli: "-t mytitle --template bug_fix.md --body-file body_file.md", wantsErr: true, }, { name: "fill-first", tty: false, cli: "--fill-first", wantsErr: false, wantsOpts: CreateOptions{ Title: "", TitleProvided: false, Body: "", BodyProvided: false, Autofill: false, FillFirst: true, RecoverFile: "", WebMode: false, IsDraft: false, BaseBranch: "", HeadBranch: "", MaintainerCanModify: true, }, }, { name: "fill and fill-first", tty: false, cli: "--fill --fill-first", wantsErr: true, }, { name: "dry-run and web", tty: false, cli: "--web --dry-run", wantsErr: true, }, { name: "editor by cli", tty: true, cli: "--editor", wantsErr: false, wantsOpts: CreateOptions{ Title: "", Body: "", RecoverFile: "", WebMode: false, EditorMode: true, MaintainerCanModify: true, }, }, { name: "editor by config", tty: true, cli: "", config: "prefer_editor_prompt: enabled", wantsErr: false, wantsOpts: CreateOptions{ Title: "", Body: "", RecoverFile: "", WebMode: false, EditorMode: true, MaintainerCanModify: true, }, }, { name: "editor and web", tty: true, cli: "--editor --web", wantsErr: true, }, { name: "can use web even though editor is enabled by config", tty: true, cli: `--web --title mytitle --body "issue body"`, config: "prefer_editor_prompt: enabled", wantsErr: false, wantsOpts: CreateOptions{ Title: "mytitle", Body: "issue body", TitleProvided: true, BodyProvided: true, RecoverFile: "", WebMode: true, EditorMode: false, MaintainerCanModify: true, }, }, { name: "editor with non-tty", tty: false, cli: "--editor", wantsErr: true, }, { name: "fill and base", cli: "--fill --base trunk", wantsOpts: CreateOptions{ Autofill: true, BaseBranch: "trunk", MaintainerCanModify: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, stdin, stdout, stderr := iostreams.Test() if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) } else if tt.tty { ios.SetStdinTTY(true) ios.SetStdoutTTY(true) } f := &cmdutil.Factory{ IOStreams: ios, Config: func() (gh.Config, error) { if tt.config != "" { return config.NewFromString(tt.config), nil } return config.NewBlankConfig(), nil }, } var opts *CreateOptions cmd := NewCmdCreate(f, func(o *CreateOptions) error { opts = o return nil }) args, err := shlex.Split(tt.cli) require.NoError(t, err) cmd.SetArgs(args) cmd.SetOut(stderr) cmd.SetErr(stderr) _, err = cmd.ExecuteC() if tt.wantsErr { assert.Error(t, err) return } else { require.NoError(t, err) } assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) assert.Equal(t, tt.wantsOpts.Body, opts.Body) assert.Equal(t, tt.wantsOpts.BodyProvided, opts.BodyProvided) assert.Equal(t, tt.wantsOpts.Title, opts.Title) assert.Equal(t, tt.wantsOpts.TitleProvided, opts.TitleProvided) assert.Equal(t, tt.wantsOpts.Autofill, opts.Autofill) assert.Equal(t, tt.wantsOpts.FillFirst, opts.FillFirst) assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile) assert.Equal(t, tt.wantsOpts.IsDraft, opts.IsDraft) assert.Equal(t, tt.wantsOpts.MaintainerCanModify, opts.MaintainerCanModify) assert.Equal(t, tt.wantsOpts.BaseBranch, opts.BaseBranch) assert.Equal(t, tt.wantsOpts.HeadBranch, opts.HeadBranch) assert.Equal(t, tt.wantsOpts.Template, opts.Template) }) } } 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) expectedOutputs []string expectedOut string expectedErrOut string expectedBrowse string wantErr string tty bool customBranchConfig bool }{ { name: "nontty web", setup: func(opts *CreateOptions, t *testing.T) func() { opts.WebMode = true opts.HeadBranch = "feature" return func() {} }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") }, expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", }, { name: "nontty", httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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"]) })) }, 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" return func() {} }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, { name: "same head and base branch should error", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.HeadBranch = "master" return func() {} }, wantErr: `head branch "master" is the same as base branch "master", cannot create a pull request`, }, { name: "same head and base branch with explicit base should error", 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.BaseBranch = "feature" return func() {} }, wantErr: `head branch "feature" is the same as base branch "feature", cannot create a pull request`, }, { 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-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-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: "select a specific branch to push to on prompt", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.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"].(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)) assert.Equal(t, false, input["draft"].(bool)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "skip pushing to branch on prompt", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.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"].(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)) assert.Equal(t, false, input["draft"].(bool)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return prompter.IndexFor(opts, "Skip pushing the branch") } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "project v2", 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.Projects = []string{"RoadmapV2"} return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) mockRetrieveProjects(t, reg) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "id": "PullRequest#1", "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(input map[string]interface{}) { 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)) assert.Equal(t, false, input["draft"].(bool)) })) reg.Register( httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), httpmock.GraphQLQuery(` { "data": { "add_000": { "item": { "id": "1" } } } } `, func(mutations string, inputs map[string]interface{}) { variables, err := json.Marshal(inputs) assert.NoError(t, err) expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}" expectedVariables := `{"input_000":{"contentId":"PullRequest#1","projectId":"ROADMAPV2ID"}}` assert.Equal(t, expectedMutations, mutations) assert.Equal(t, expectedVariables, string(variables)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "no maintainer modify", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.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)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "create fork", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "title" opts.Body = "body" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/forks"), httpmock.RESTPayload(201, ` { "node_id": "NODEID", "name": "REPO", "owner": {"login": "monalisa"} }`, func(payload map[string]interface{}) { assert.Equal(t, true, payload["default_branch_only"]) })) reg.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"].(string)) assert.Equal(t, "master", input["baseRefName"].(string)) assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 1, "") cs.Register("git config remote.pushDefault", 1, "") cs.Register("git config push.default", 1, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register("git remote rename origin upstream", 0, "") cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return prompter.IndexFor(opts, "Create a fork of OWNER/REPO") } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\nChanged OWNER/REPO remote to \"upstream\"\nAdded monalisa/REPO as remote \"origin\"\n! Repository monalisa/REPO set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", }, { name: "pushed to non base repo", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "title" opts.Body = "body" opts.Remotes = func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, { Remote: &git.Remote{ Name: "origin", Resolved: "base", }, Repo: ghrepo.New("monalisa", "REPO"), }, }, nil } return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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"].(string)) assert.Equal(t, "master", input["baseRefName"].(string)) assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 0, heredoc.Doc(` deadbeef HEAD deadbeef refs/remotes/origin/feature`)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n", }, { name: "pushed to different branch name", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "title" opts.Body = "body" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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"].(string)) assert.Equal(t, "master", input["baseRefName"].(string)) assert.Equal(t, "my-feat2", input["headRefName"].(string)) })) }, customBranchConfig: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(` branch.feature.remote origin branch.feature.merge refs/heads/my-feat2 `)) cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/my-feat2") cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/my-feat2", 0, heredoc.Doc(` deadbeef HEAD deadbeef refs/remotes/origin/my-feat2 `)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n", }, { name: "non legacy template", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.Title = "my title" opts.HeadBranch = "feature" opts.RootDirOverride = "./fixtures/repoWithNonLegacyPRTemplates" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequestTemplates": [ { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", "body": "this is an enhancement" } ] } } }`)) reg.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, "my title", input["title"].(string)) assert.Equal(t, "- **commit 1**\n- **commit 0**\n\nthis is a bug", input["body"].(string)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "d3476a1\u0000commit 0\u0000\u0000\n7a6ea13\u0000commit 1\u0000\u0000") }, promptStubs: func(pm *prompter.PrompterMock) { pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) { if p == "Body" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { switch p { case "What's next?": return 0, nil case "Choose a template": return prompter.IndexFor(opts, "template1") default: return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "metadata", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.Title = "TITLE" opts.BodyProvided = true opts.Body = "BODY" opts.HeadBranch = "feature" opts.Assignees = []string{"monalisa"} opts.Labels = []string{"bug", "todo"} opts.Projects = []string{"roadmap"} opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` { "data": { "repository": { "labels": { "nodes": [ { "name": "TODO", "id": "TODOID" }, { "name": "bug", "id": "BUGID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) 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) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "id": "NEWPULLID", "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { 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) } if v, ok := inputs["userIds"]; ok { t.Errorf("did not expect userIds: %v", v) } })) reg.Register( httpmock.GraphQL(`mutation PullRequestCreateMetadata\b`), httpmock.GraphQLMutation(` { "data": { "updatePullRequest": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) if _, ok := inputs["assigneeIds"]; ok { t.Error("did not expect assigneeIds in updatePullRequest when ApiActorsSupported is true") } assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) assert.Equal(t, "BIGONEID", inputs["milestoneId"]) })) reg.Register( httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), httpmock.GraphQLMutation(` { "data": { "replaceActorsForAssignable": { "__typename": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["assignableId"]) assert.Equal(t, []interface{}{"monalisa"}, inputs["actorLogins"]) })) reg.Register( httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), httpmock.GraphQLMutation(` { "data": { "requestReviewsByLogin": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) assert.Equal(t, []interface{}{"OWNER/core", "OWNER/robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "already exists", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "title" opts.Body = "body" opts.HeadBranch = "feature" opts.Finder = shared.NewMockFinder("feature", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/123"}, ghrepo.New("OWNER", "REPO")) return func() {} }, wantErr: "a pull request for branch \"feature\" into branch \"master\" already exists:\nhttps://github.com/OWNER/REPO/pull/123", }, { name: "web", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.WebMode = true return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedErrOut: "Opening https://github.com/OWNER/REPO/compare/master...feature in your browser.\n", expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", }, { name: "web project", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.WebMode = true opts.Projects = []string{"Triage"} return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) mockRetrieveProjects(t, reg) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, expectedErrOut: "Opening https://github.com/OWNER/REPO/compare/master...feature in your browser.\n", expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1&projects=ORG%2F1", }, { name: "draft", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.Title = "my title" opts.HeadBranch = "feature" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequestTemplates": [ { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", "body": "this is an enhancement" } ] } } }`), ) reg.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, true, input["draft"].(bool)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature`, 0, "") cs.Register(`git rev-parse --show-toplevel`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) { if p == "Body" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { switch p { case "What's next?": return prompter.IndexFor(opts, "Submit as draft") case "Choose a template": return 0, nil default: return -1, prompter.NoSuchPromptErr(p) } } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "recover", tty: true, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "jillValentine", "id": "JILLID", "name": "Jill Valentine" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), httpmock.GraphQLMutation(` { "data": { "requestReviews": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"]) })) reg.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)) })) }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.InputFunc = func(p, d string) (string, error) { if p == "Title (required)" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) { if p == "Body" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "What's next?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, setup: func(opts *CreateOptions, t *testing.T) func() { tmpfile, err := os.CreateTemp(t.TempDir(), "testrecover*") assert.NoError(t, err) state := shared.IssueMetadataState{ Title: "recovered title", Body: "recovered body", Reviewers: []string{"jillValentine"}, } data, err := json.Marshal(state) assert.NoError(t, err) _, err = tmpfile.Write(data) assert.NoError(t, err) opts.RecoverFile = tmpfile.Name() opts.HeadBranch = "feature" return func() { tmpfile.Close() } }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "web long URL", cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") }, setup: func(opts *CreateOptions, t *testing.T) func() { longBody := make([]byte, 9216) opts.Body = string(longBody) opts.BodyProvided = true opts.WebMode = true opts.HeadBranch = "feature" return func() {} }, wantErr: "cannot open in browser: maximum URL length exceeded", }, { name: "single commit title and body are used", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.HeadBranch = "feature" return func() {} }, cmdStubs: func(cs *run.CommandStubber) { cs.Register( "git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "3a9b48085046d156c5acce8f3b3a0532cd706a4a\u0000first commit of pr\u0000first commit description\u0000\n", ) cs.Register(`git rev-parse --show-toplevel`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "Where should we push the 'feature' branch?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } pm.InputFunc = func(p, d string) (string, error) { if p == "Title (required)" { return d, nil } else if p == "Body" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) { if p == "Body" { return d, nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { if p == "What's next?" { return 0, nil } else { return -1, prompter.NoSuchPromptErr(p) } } }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), ) reg.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, "first commit of pr", input["title"], "pr title should be first commit message") assert.Equal(t, "first commit description", input["body"], "pr body should be first commit description") }, ), ) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "fill-first flag provided", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.FillFirst = true opts.HeadBranch = "feature" return func() {} }, cmdStubs: func(cs *run.CommandStubber) { cs.Register( "git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "56b6f8bb7c9e3a30093cd17e48934ce354148e80\u0000second commit of pr\u0000\u0000\n"+ "3a9b48085046d156c5acce8f3b3a0532cd706a4a\u0000first commit of pr\u0000first commit description\u0000\n", ) }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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, "first commit of pr", input["title"], "pr title should be first commit message") assert.Equal(t, "first commit description", input["body"], "pr body should be first commit description") }, ), ) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "fillverbose flag provided", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.FillVerbose = true opts.HeadBranch = "feature" return func() {} }, cmdStubs: func(cs *run.CommandStubber) { cs.Register( "git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "56b6f8bb7c9e3a30093cd17e48934ce354148e80\u0000second commit of pr\u0000second commit description\u0000\n"+ "3a9b48085046d156c5acce8f3b3a0532cd706a4a\u0000first commit of pr\u0000first commit with super long description, with super long description, with super long description, with super long description.\u0000\n", ) }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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, "feature", input["title"], "pr title should be branch name") assert.Equal(t, "- **first commit of pr**\n first commit with super long description, with super long description, with super long description, with super long description.\n\n- **second commit of pr**\n second commit description", input["body"], "pr body should be commits msg+body") }, ), ) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, { name: "editor", httpStubs: func(r *httpmock.Registry, t *testing.T) { r.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "title", inputs["title"]) assert.Equal(t, "body", inputs["body"]) })) }, setup: func(opts *CreateOptions, t *testing.T) func() { opts.EditorMode = true opts.HeadBranch = "feature" opts.TitledEditSurvey = func(string, string) (string, string, error) { return "title", "body", nil } return func() {} }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, { name: "gh-merge-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.Branch = func() (string, error) { return "task1", nil } opts.Remotes = func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, { Remote: &git.Remote{ Name: "origin", }, Repo: ghrepo.New("monalisa", "REPO"), }, }, nil } return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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"].(string)) assert.Equal(t, "my title", input["title"].(string)) assert.Equal(t, "my body", input["body"].(string)) assert.Equal(t, "feature/feat2", input["baseRefName"].(string)) assert.Equal(t, "monalisa:task1", input["headRefName"].(string)) })) }, customBranchConfig: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, heredoc.Doc(` branch.task1.remote origin branch.task1.merge refs/heads/task1 branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig cs.Register("git rev-parse --symbolic-full-name task1@{push}", 0, "refs/remotes/origin/task1") cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/task1`, 0, heredoc.Doc(` deadbeef HEAD deadbeef refs/remotes/origin/task1`)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", }, { name: "--head contains : syntax", httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.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, "otherowner:feature", input["headRefName"]) })) }, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.HeadBranch = "otherowner:feature" return func() {} }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, { name: "request reviewers by login", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.Reviewers = []string{"hubot", "monalisa", "org/core", "org/robots"} opts.HeadBranch = "feature" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12", "id": "NEWPULLID" } } } }`, func(input map[string]interface{}) {})) reg.Register( httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), httpmock.GraphQLMutation(` { "data": { "requestReviewsByLogin": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) assert.Equal(t, []interface{}{"org/core", "org/robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, { name: "@copilot reviewer resolves to bot login", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.Reviewers = []string{"hubot", "@copilot"} opts.HeadBranch = "feature" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12", "id": "NEWPULLID" } } } }`, func(input map[string]interface{}) {})) reg.Register( httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), httpmock.GraphQLMutation(` { "data": { "requestReviewsByLogin": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot"}, inputs["userLogins"]) assert.Equal(t, []interface{}{"copilot-pull-request-reviewer[bot]"}, inputs["botLogins"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { branch := "feature" reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) if tt.httpStubs != nil { tt.httpStubs(reg, t) } pm := &prompter.PrompterMock{} if tt.promptStubs != nil { tt.promptStubs(pm) } cs, cmdTeardown := run.Stub() defer cmdTeardown(t) if !tt.customBranchConfig { cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") } if tt.cmdStubs != nil { tt.cmdStubs(cs) } opts := CreateOptions{} opts.Prompter = pm ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) browser := &browser.Stub{} opts.IO = ios opts.Browser = browser opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } opts.Remotes = func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "origin", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil } opts.Branch = func() (string, error) { return branch, nil } opts.Finder = shared.NewMockFinder(branch, nil, nil) opts.GitClient = &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", } cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() // All tests in this function use github.com behavior opts.Detector = &fd.EnabledDetectorMock{} if opts.HeadBranch == "" { cs.Register(`git status --porcelain`, 0, "") } err := createRun(&opts) output := &test.CmdOut{ OutBuf: stdout, ErrBuf: stderr, BrowsedURL: browser.BrowsedURL(), } if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { assert.NoError(t, err) 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) } }) } } func Test_createRun_GHES(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) expectedOutputs []string expectedOut string expectedErrOut string tty bool customBranchConfig bool }{ { 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.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" opts.DryRun = true return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "hubot", "id": "HUBOTID", "name": "" }, { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` { "data": { "repository": { "labels": { "nodes": [ { "name": "TODO", "id": "TODOID" }, { "name": "bug", "id": "BUGID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) 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 } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), httpmock.StringResponse(` { "data": { "organization": { "teams": { "nodes": [ { "slug": "core", "id": "COREID" }, { "slug": "robots", "id": "ROBOTID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) }, expectedOutputs: []string{ "Would have created a Pull Request with:", `title: TITLE`, `draft: false`, `base: trunk`, `head: feature`, `labels: bug, todo`, `reviewers: hubot, monalisa, OWNER/core, OWNER/robots`, `assignees: monalisa`, `milestones: big one.oh`, `maintainerCanModify: false`, `body:`, `BODY`, ``, }, expectedErrOut: "", }, { 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.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" opts.DryRun = true return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "hubot", "id": "HUBOTID", "name": "" }, { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` { "data": { "repository": { "labels": { "nodes": [ { "name": "TODO", "id": "TODOID" }, { "name": "bug", "id": "BUGID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) 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 } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), httpmock.StringResponse(` { "data": { "organization": { "teams": { "nodes": [ { "slug": "core", "id": "COREID" }, { "slug": "robots", "id": "ROBOTID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) }, expectedOutputs: []string{ `Would have created a Pull Request with:`, `Title: TITLE`, `Draft: false`, `Base: trunk`, `Head: feature`, `Labels: bug, todo`, `Reviewers: hubot, monalisa, OWNER/core, OWNER/robots`, `Assignees: monalisa`, `Milestones: big one.oh`, `MaintainerCanModify: false`, `Body:`, ``, ` BODY `, ``, ``, }, expectedErrOut: heredoc.Doc(` Dry Running pull request for feature into trunk in OWNER/REPO `), }, { name: "fetch org teams non-interactively if reviewer contains any team", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.Reviewers = []string{"hubot", "monalisa", "org/core", "org/robots"} opts.HeadBranch = "feature" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12", "id": "NEWPULLID" } } } }`, func(input map[string]interface{}) {})) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "hubot", "id": "HUBOTID" }, { "login": "MonaLisa", "id": "MONAID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(` { "data": { "viewer": { "login": "monalisa" } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), httpmock.StringResponse(` { "data": { "organization": { "teams": { "nodes": [ { "slug": "core", "id": "COREID" }, { "slug": "robots", "id": "ROBOTID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), httpmock.GraphQLMutation(` { "data": { "requestReviews": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { 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"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, { name: "do not fetch org teams non-interactively if reviewer does not contain any team", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true opts.Title = "my title" opts.Body = "my body" opts.Reviewers = []string{"hubot", "monalisa"} opts.HeadBranch = "feature" return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12", "id": "NEWPULLID" } } } }`, func(input map[string]interface{}) {})) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "hubot", "id": "HUBOTID" }, { "login": "MonaLisa", "id": "MONAID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(` { "data": { "viewer": { "login": "monalisa" } } } `)) reg.Exclude( t, httpmock.GraphQL(`query OrganizationTeamList\b`), ) reg.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), httpmock.GraphQLMutation(` { "data": { "requestReviews": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) assert.NotEqual(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, { name: "fetch org teams interactively if reviewer metadata selected", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { // In order to test additional metadata, title and body cannot be provided here. opts.HeadBranch = "feature" return func() {} }, cmdStubs: func(cs *run.CommandStubber) { // Stub git commits for `initDefaultTitleBody` when initializing PR state. cs.Register( "git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "3a9b48085046d156c5acce8f3b3a0532cd706a4a\u0000first commit of pr\u0000first commit description\u0000\n", ) cs.Register(`git rev-parse --show-toplevel`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { firstConfirmSubmission := true pm.InputFunc = func(message, defaultValue string) (string, error) { switch message { case "Title (required)": return "TITLE", nil default: return "", fmt.Errorf("unexpected input prompt: %s", message) } } pm.MarkdownEditorFunc = func(message, defaultValue string, allowEmpty bool) (string, error) { switch message { case "Body": return "BODY", nil default: return "", fmt.Errorf("unexpected markdown editor prompt: %s", message) } } pm.MultiSelectFunc = func(message string, defaults []string, options []string) ([]int, error) { switch message { case "What would you like to add?": return prompter.IndexesFor(options, "Reviewers") case "Reviewers": return prompter.IndexesFor(options, "MonaLisa (Mona Display Name)", "OWNER/core") default: return nil, fmt.Errorf("unexpected multi-select prompt: %s", message) } } pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "Where should we push the 'feature' branch?": return 0, nil case "What's next?": if firstConfirmSubmission { firstConfirmSubmission = false return prompter.IndexFor(options, "Add metadata") } return prompter.IndexFor(options, "Submit") default: return 0, fmt.Errorf("unexpected select prompt: %s", message) } } }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), ) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` { "data": { "repository": { "assignableUsers": { "nodes": [ { "login": "hubot", "id": "HUBOTID", "name": "" }, { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), httpmock.StringResponse(` { "data": { "organization": { "teams": { "nodes": [ { "slug": "core", "id": "COREID" }, { "slug": "robots", "id": "ROBOTID" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "id": "NEWPULLID", "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { 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) } if v, ok := inputs["userIds"]; ok { t.Errorf("did not expect userIds: %v", v) } })) reg.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), httpmock.GraphQLMutation(` { "data": { "requestReviews": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"COREID"}, inputs["teamIds"]) assert.Equal(t, []interface{}{"MONAID"}, inputs["userIds"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { branch := "feature" reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) if tt.httpStubs != nil { tt.httpStubs(reg, t) } pm := &prompter.PrompterMock{} if tt.promptStubs != nil { tt.promptStubs(pm) } cs, cmdTeardown := run.Stub() defer cmdTeardown(t) if !tt.customBranchConfig { cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") } if tt.cmdStubs != nil { tt.cmdStubs(cs) } opts := CreateOptions{} opts.Detector = &fd.DisabledDetectorMock{} opts.Prompter = pm ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) browser := &browser.Stub{} opts.IO = ios opts.Browser = browser opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } opts.Remotes = func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "origin", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil } opts.Branch = func() (string, error) { return branch, nil } opts.Finder = shared.NewMockFinder(branch, nil, nil) opts.GitClient = &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", } cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() if opts.HeadBranch == "" { cs.Register(`git status --porcelain`, 0, "") } err := createRun(&opts) output := &test.CmdOut{ OutBuf: stdout, ErrBuf: stderr, } assert.NoError(t, err) 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()) }) } } func TestRemoteGuessing(t *testing.T) { // Given git config does not provide the necessary info to determine a remote cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git status --porcelain`, 0, "") cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") cs.Register("git config remote.pushDefault", 1, "") cs.Register("git config push.default", 1, "") // And Given there is a remote on a SHA that matches the current HEAD cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature`, 0, heredoc.Doc(` deadbeef HEAD deadb00f refs/remotes/upstream/feature deadbeef refs/remotes/origin/feature`)) // When the command is run reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) reg.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"].(string)) assert.Equal(t, "master", input["baseRefName"].(string)) assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) })) ios, _, _, _ := iostreams.Test() opts := CreateOptions{ HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, Browser: &browser.Stub{}, IO: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Finder: shared.NewMockFinder("feature", nil, nil), Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, { Remote: &git.Remote{ Name: "origin", }, Repo: ghrepo.New("OTHEROWNER", "REPO-FORK"), }, }, nil }, Branch: func() (string, error) { return "feature", nil }, TitleProvided: true, BodyProvided: true, Title: "my title", Body: "my body", } require.NoError(t, createRun(&opts)) // Then guessed remote is used for the PR head, // which annoyingly, is asserted above on the line: // assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) // // This is because OTHEROWNER relates to the "origin" remote, which has a // SHA that matches the HEAD ref in the `git show-ref` output. } func TestNoRepoCanBeDetermined(t *testing.T) { // Given no head repo can be determined from git config cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git status --porcelain`, 0, "") cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") cs.Register("git config remote.pushDefault", 1, "") cs.Register("git config push.default", 1, "") // And Given there is no remote on the correct SHA cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, heredoc.Doc(` deadbeef HEAD deadb00f refs/remotes/origin/feature`)) // When the command is run with no TTY reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) ios, _, _, stderr := iostreams.Test() opts := CreateOptions{ HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, Browser: &browser.Stub{}, IO: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Finder: shared.NewMockFinder("feature", nil, nil), Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "origin", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Branch: func() (string, error) { return "feature", nil }, TitleProvided: true, BodyProvided: true, Title: "my title", Body: "my body", } // When we run the command err := createRun(&opts) // Then create fails require.Equal(t, cmdutil.SilentError, err) assert.Equal(t, "aborted: you must first push the current branch to a remote, or use the --head flag\n", stderr.String()) } func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef { parsed, err := shared.ParseQualifiedHeadRef(ref) if err != nil { panic(err) } return parsed } func Test_generateCompareURL(t *testing.T) { tests := []struct { name string ctx CreateContext state shared.IssueMetadataState httpStubs func(*testing.T, *httpmock.Registry) projectsV1Support gh.ProjectsV1Support want string wantErr bool }{ { name: "basic", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, }, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1", wantErr: false, }, { name: "with labels", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("b"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "a", }, }, }, state: shared.IssueMetadataState{ Labels: []string{"one", "two three"}, }, want: "https://github.com/OWNER/REPO/compare/a...b?body=&expand=1&labels=one%2Ctwo+three", wantErr: false, }, { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:feature"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), baseBranchName: "main/trunk", }, }, }, want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1", wantErr: false, }, { name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ", /* - Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway - !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[] - : is GitHub separator between a fork name and a branch name - See https://github.com/golang/go/issues/27559. */ ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:!$&'()+,;=@"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), baseBranchName: "main/trunk", }, }, }, want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, }, { name: "with template", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, }, state: shared.IssueMetadataState{ Template: "story.md", }, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&template=story.md", wantErr: false, }, // TODO projectsV1Deprecation // Clean up these tests, but probably keep one for general project ID resolution. { name: "with projects, no v1 support", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { // Ensure no v1 projects are requestd // ( is required to avoid matching projectsV2 reg.Exclude(t, httpmock.GraphQL(`projects\(`)) reg.Register( httpmock.GraphQL(`query RepositoryProjectV2List\b`), httpmock.StringResponse(` { "data": { "repository": { "projectsV2": { "nodes": [ { "title": "ProjectTitle", "id": "PROJECTV2ID", "resourcePath": "/OWNER/REPO/projects/3" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectV2List\b`), httpmock.StringResponse(` { "data": { "organization": { "projectsV2": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query UserProjectV2List\b`), httpmock.StringResponse(` { "data": { "viewer": { "projectsV2": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) }, state: shared.IssueMetadataState{ ProjectTitles: []string{"ProjectTitle"}, }, projectsV1Support: gh.ProjectsV1Unsupported, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F3", wantErr: false, }, { name: "with projects, v1 support", ctx: CreateContext{ PRRefs: &skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, }, state: shared.IssueMetadataState{ ProjectTitles: []string{"ProjectV1Title"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { // v1 project query responses reg.Register( httpmock.GraphQL(`query RepositoryProjectList\b`), httpmock.StringResponse(` { "data": { "repository": { "projects": { "nodes": [ { "name": "ProjectV1Title", "id": "PROJECTV1ID", "resourcePath": "/OWNER/REPO/projects/1" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), httpmock.StringResponse(` { "data": { "organization": { "projects": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) // v2 project query responses reg.Register( httpmock.GraphQL(`query RepositoryProjectV2List\b`), httpmock.StringResponse(` { "data": { "repository": { "projectsV2": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectV2List\b`), httpmock.StringResponse(` { "data": { "organization": { "projectsV2": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query UserProjectV2List\b`), httpmock.StringResponse(` { "data": { "viewer": { "projectsV2": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } } `)) }, projectsV1Support: gh.ProjectsV1Supported, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F1", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // If http stubs are provided, register them and inject the registry into a client // that is provided to generateCompareURL in the ctx. if tt.httpStubs != nil { reg := &httpmock.Registry{} defer reg.Verify(t) tt.httpStubs(t, reg) tt.ctx.Client = api.NewClientFromHTTP(&http.Client{Transport: reg}) } got, err := generateCompareURL(tt.ctx, tt.state, tt.projectsV1Support) if (err != nil) != tt.wantErr { t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("generateCompareURL() = %v, want %v", got, tt.want) } }) } } func mockRetrieveProjects(_ *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryProjectList\b`), httpmock.StringResponse(` { "data": { "repository": { "projects": { "nodes": [ { "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }, { "name": "Roadmap", "id": "ROADMAPID", "resourcePath": "/OWNER/REPO/projects/2" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query RepositoryProjectV2List\b`), httpmock.StringResponse(` { "data": { "repository": { "projectsV2": { "nodes": [ { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" }, { "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), httpmock.StringResponse(` { "data": { "organization": { "projects": { "nodes": [ { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectV2List\b`), httpmock.StringResponse(` { "data": { "organization": { "projectsV2": { "nodes": [ { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } ], "pageInfo": { "hasNextPage": false } } } } } `)) reg.Register( httpmock.GraphQL(`query UserProjectV2List\b`), httpmock.StringResponse(` { "data": { "viewer": { "projectsV2": { "nodes": [ { "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/user/MONALISA/projects/2" } ], "pageInfo": { "hasNextPage": false } } } } } `)) } // TODO projectsV1Deprecation // Remove this test. func TestProjectsV1Deprecation(t *testing.T) { t.Run("non-interactive submission", func(t *testing.T) { t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "main") reg.Register( // ( is required to avoid matching projectsV2 httpmock.GraphQL(`projects\(`), // Simulate a GraphQL error to early exit the test. httpmock.StatusStringResponse(500, ""), ) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. _ = createRun(&CreateOptions{ Detector: &fd.EnabledDetectorMock{}, IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Finder: shared.NewMockFinder("feature", nil, nil), HeadBranch: "feature", TitleProvided: true, BodyProvided: true, Title: "Test Title", Body: "Test Body", // Required to force a lookup of projects Projects: []string{"Project"}, }) // Verify that our request contained projects reg.Verify(t) }) t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "main") // ( is required to avoid matching projectsV2 reg.Exclude(t, httpmock.GraphQL(`projects\(`)) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") // Ignore the error because we're not really interested in it. _ = createRun(&CreateOptions{ Detector: &fd.DisabledDetectorMock{}, IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Finder: shared.NewMockFinder("feature", nil, nil), HeadBranch: "feature", TitleProvided: true, BodyProvided: true, Title: "Test Title", Body: "Test Body", // Required to force a lookup of projects Projects: []string{"Project"}, }) // Verify that our request contained projectCards reg.Verify(t) }) }) t.Run("interactive submission", func(t *testing.T) { t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "") cs.Register(`git rev-parse --show-toplevel`, 0, "") // When the command is run reg := &httpmock.Registry{} reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), ) reg.Register( // ( is required to avoid matching projectsV2 httpmock.GraphQL(`projects\(`), // Simulate a GraphQL error to early exit the test. httpmock.StatusStringResponse(500, ""), ) // Register a handler to check for projects V2 just to avoid the registry panicking, even // though we return a 500 error. This is because the project lookup is done in parallel // so the previous error doesn't early exit. reg.Register( httpmock.GraphQL(`projectsV2`), // Simulate a GraphQL error to early exit the test. httpmock.StatusStringResponse(500, ""), ) ios, _, _, _ := iostreams.Test() ios.SetStdinTTY(true) ios.SetStdoutTTY(true) ios.SetStderrTTY(true) pm := &prompter.PrompterMock{} pm.InputFunc = func(p, _ string) (string, error) { if p == "Title (required)" { return "Test Title", nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) { if p == "Body" { return "Test Body", nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { switch p { case "Choose a template": return 0, nil case "What's next?": return prompter.IndexFor(opts, "Add metadata") default: return -1, prompter.NoSuchPromptErr(p) } } pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) { return prompter.IndexesFor(opts, "Projects") } opts := CreateOptions{ HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, Browser: &browser.Stub{}, IO: ios, Prompter: pm, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Finder: shared.NewMockFinder("feature", nil, nil), Detector: &fd.EnabledDetectorMock{}, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "origin", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Branch: func() (string, error) { return "feature", nil }, HeadBranch: "feature", } // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. _ = createRun(&opts) // Verify that our request contained projects reg.Verify(t) }) t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "") cs.Register(`git rev-parse --show-toplevel`, 0, "") // When the command is run reg := &httpmock.Registry{} reg.StubRepoResponse("OWNER", "REPO") reg.Register( httpmock.GraphQL(`query PullRequestTemplates\b`), httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), ) // ( is required to avoid matching projectsV2 reg.Exclude(t, httpmock.GraphQL(`projects\(`)) ios, _, _, _ := iostreams.Test() ios.SetStdinTTY(true) ios.SetStdoutTTY(true) ios.SetStderrTTY(true) pm := &prompter.PrompterMock{} pm.InputFunc = func(p, _ string) (string, error) { if p == "Title (required)" { return "Test Title", nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) { if p == "Body" { return "Test Body", nil } else { return "", prompter.NoSuchPromptErr(p) } } pm.SelectFunc = func(p, _ string, opts []string) (int, error) { switch p { case "Choose a template": return 0, nil case "What's next?": return prompter.IndexFor(opts, "Add metadata") default: return -1, prompter.NoSuchPromptErr(p) } } pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) { return prompter.IndexesFor(opts, "Projects") } opts := CreateOptions{ HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, Browser: &browser.Stub{}, IO: ios, Prompter: pm, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Finder: shared.NewMockFinder("feature", nil, nil), Detector: &fd.DisabledDetectorMock{}, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "origin", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Branch: func() (string, error) { return "feature", nil }, HeadBranch: "feature", } // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. _ = createRun(&opts) // Verify that our request did not contain projectCards reg.Verify(t) }) }) t.Run("web mode", func(t *testing.T) { t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "main") reg.Register( // ( is required to avoid matching projectsV2 httpmock.GraphQL(`projects\(`), // Simulate a GraphQL error to early exit the test. httpmock.StatusStringResponse(500, ""), ) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. _ = createRun(&CreateOptions{ Detector: &fd.EnabledDetectorMock{}, IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Finder: shared.NewMockFinder("feature", nil, nil), WebMode: true, HeadBranch: "feature", TitleProvided: true, BodyProvided: true, Title: "Test Title", Body: "Test Body", // Required to force a lookup of projects Projects: []string{"Project"}, }) // Verify that our request contained projects reg.Verify(t) }) t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "main") // ( is required to avoid matching projectsV2 reg.Exclude(t, httpmock.GraphQL(`projects\(`)) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") // Ignore the error because we're not really interested in it. _ = createRun(&CreateOptions{ Detector: &fd.DisabledDetectorMock{}, IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", }, Remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{ Name: "upstream", Resolved: "base", }, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, Finder: shared.NewMockFinder("feature", nil, nil), WebMode: true, HeadBranch: "feature", TitleProvided: true, BodyProvided: true, Title: "Test Title", Body: "Test Body", // Required to force a lookup of projects Projects: []string{"Project"}, }) // Verify that our request did not contain projectCards reg.Verify(t) }) }) } func Test_isSameRef(t *testing.T) { tests := []struct { name string refs creationRefs expected bool }{ { name: "same branch in same repo", refs: skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("main"), baseRefs: baseRefs{ baseBranchName: "main", }, }, expected: true, }, { name: "different branches in same repo", refs: skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), baseRefs: baseRefs{ baseBranchName: "main", }, }, expected: false, }, { name: "same branch name in different repos (cross-repo PR)", refs: skipPushRefs{ qualifiedHeadRef: shared.NewQualifiedHeadRef("other-owner", "main"), baseRefs: baseRefs{ baseBranchName: "main", }, }, expected: false, }, { name: "pushableRefs same branch same repo", refs: pushableRefs{ headRepo: ghrepo.New("OWNER", "REPO"), headBranchName: "main", baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, expected: true, }, { name: "pushableRefs same branch different repos (fork)", refs: pushableRefs{ headRepo: ghrepo.New("FORK-OWNER", "REPO"), headBranchName: "main", baseRefs: baseRefs{ baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), baseBranchName: "main", }, }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isSameRef(tt.refs) assert.Equal(t, tt.expected, result) }) } }