github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/create/create_test.go (about)

     1  package create
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"os"
     9  	"path/filepath"
    10  	"testing"
    11  
    12  	"github.com/MakeNowJust/heredoc"
    13  	"github.com/ungtb10d/cli/v2/api"
    14  	"github.com/ungtb10d/cli/v2/context"
    15  	"github.com/ungtb10d/cli/v2/git"
    16  	"github.com/ungtb10d/cli/v2/internal/browser"
    17  	"github.com/ungtb10d/cli/v2/internal/config"
    18  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    19  	"github.com/ungtb10d/cli/v2/internal/run"
    20  	"github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared"
    21  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    22  	"github.com/ungtb10d/cli/v2/pkg/httpmock"
    23  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    24  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    25  	"github.com/ungtb10d/cli/v2/test"
    26  	"github.com/google/shlex"
    27  	"github.com/stretchr/testify/assert"
    28  	"github.com/stretchr/testify/require"
    29  )
    30  
    31  func TestNewCmdCreate(t *testing.T) {
    32  	tmpFile := filepath.Join(t.TempDir(), "my-body.md")
    33  	err := os.WriteFile(tmpFile, []byte("a body from file"), 0600)
    34  	require.NoError(t, err)
    35  
    36  	tests := []struct {
    37  		name      string
    38  		tty       bool
    39  		stdin     string
    40  		cli       string
    41  		wantsErr  bool
    42  		wantsOpts CreateOptions
    43  	}{
    44  		{
    45  			name:     "empty non-tty",
    46  			tty:      false,
    47  			cli:      "",
    48  			wantsErr: true,
    49  		},
    50  		{
    51  			name:     "only title non-tty",
    52  			tty:      false,
    53  			cli:      "--title mytitle",
    54  			wantsErr: true,
    55  		},
    56  		{
    57  			name:     "minimum non-tty",
    58  			tty:      false,
    59  			cli:      "--title mytitle --body ''",
    60  			wantsErr: false,
    61  			wantsOpts: CreateOptions{
    62  				Title:               "mytitle",
    63  				TitleProvided:       true,
    64  				Body:                "",
    65  				BodyProvided:        true,
    66  				Autofill:            false,
    67  				RecoverFile:         "",
    68  				WebMode:             false,
    69  				IsDraft:             false,
    70  				BaseBranch:          "",
    71  				HeadBranch:          "",
    72  				MaintainerCanModify: true,
    73  			},
    74  		},
    75  		{
    76  			name:     "empty tty",
    77  			tty:      true,
    78  			cli:      "",
    79  			wantsErr: false,
    80  			wantsOpts: CreateOptions{
    81  				Title:               "",
    82  				TitleProvided:       false,
    83  				Body:                "",
    84  				BodyProvided:        false,
    85  				Autofill:            false,
    86  				RecoverFile:         "",
    87  				WebMode:             false,
    88  				IsDraft:             false,
    89  				BaseBranch:          "",
    90  				HeadBranch:          "",
    91  				MaintainerCanModify: true,
    92  			},
    93  		},
    94  		{
    95  			name:     "body from stdin",
    96  			tty:      false,
    97  			stdin:    "this is on standard input",
    98  			cli:      "-t mytitle -F -",
    99  			wantsErr: false,
   100  			wantsOpts: CreateOptions{
   101  				Title:               "mytitle",
   102  				TitleProvided:       true,
   103  				Body:                "this is on standard input",
   104  				BodyProvided:        true,
   105  				Autofill:            false,
   106  				RecoverFile:         "",
   107  				WebMode:             false,
   108  				IsDraft:             false,
   109  				BaseBranch:          "",
   110  				HeadBranch:          "",
   111  				MaintainerCanModify: true,
   112  			},
   113  		},
   114  		{
   115  			name:     "body from file",
   116  			tty:      false,
   117  			cli:      fmt.Sprintf("-t mytitle -F '%s'", tmpFile),
   118  			wantsErr: false,
   119  			wantsOpts: CreateOptions{
   120  				Title:               "mytitle",
   121  				TitleProvided:       true,
   122  				Body:                "a body from file",
   123  				BodyProvided:        true,
   124  				Autofill:            false,
   125  				RecoverFile:         "",
   126  				WebMode:             false,
   127  				IsDraft:             false,
   128  				BaseBranch:          "",
   129  				HeadBranch:          "",
   130  				MaintainerCanModify: true,
   131  			},
   132  		},
   133  	}
   134  	for _, tt := range tests {
   135  		t.Run(tt.name, func(t *testing.T) {
   136  			ios, stdin, stdout, stderr := iostreams.Test()
   137  			if tt.stdin != "" {
   138  				_, _ = stdin.WriteString(tt.stdin)
   139  			} else if tt.tty {
   140  				ios.SetStdinTTY(true)
   141  				ios.SetStdoutTTY(true)
   142  			}
   143  
   144  			f := &cmdutil.Factory{
   145  				IOStreams: ios,
   146  			}
   147  
   148  			var opts *CreateOptions
   149  			cmd := NewCmdCreate(f, func(o *CreateOptions) error {
   150  				opts = o
   151  				return nil
   152  			})
   153  
   154  			args, err := shlex.Split(tt.cli)
   155  			require.NoError(t, err)
   156  			cmd.SetArgs(args)
   157  			cmd.SetOut(stderr)
   158  			cmd.SetErr(stderr)
   159  			_, err = cmd.ExecuteC()
   160  			if tt.wantsErr {
   161  				assert.Error(t, err)
   162  				return
   163  			} else {
   164  				require.NoError(t, err)
   165  			}
   166  
   167  			assert.Equal(t, "", stdout.String())
   168  			assert.Equal(t, "", stderr.String())
   169  
   170  			assert.Equal(t, tt.wantsOpts.Body, opts.Body)
   171  			assert.Equal(t, tt.wantsOpts.BodyProvided, opts.BodyProvided)
   172  			assert.Equal(t, tt.wantsOpts.Title, opts.Title)
   173  			assert.Equal(t, tt.wantsOpts.TitleProvided, opts.TitleProvided)
   174  			assert.Equal(t, tt.wantsOpts.Autofill, opts.Autofill)
   175  			assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode)
   176  			assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile)
   177  			assert.Equal(t, tt.wantsOpts.IsDraft, opts.IsDraft)
   178  			assert.Equal(t, tt.wantsOpts.MaintainerCanModify, opts.MaintainerCanModify)
   179  			assert.Equal(t, tt.wantsOpts.BaseBranch, opts.BaseBranch)
   180  			assert.Equal(t, tt.wantsOpts.HeadBranch, opts.HeadBranch)
   181  		})
   182  	}
   183  }
   184  
   185  func Test_createRun(t *testing.T) {
   186  	tests := []struct {
   187  		name           string
   188  		setup          func(*CreateOptions, *testing.T) func()
   189  		cmdStubs       func(*run.CommandStubber)
   190  		askStubs       func(*prompt.AskStubber) // TODO eventually migrate to PrompterMock
   191  		httpStubs      func(*httpmock.Registry, *testing.T)
   192  		expectedOut    string
   193  		expectedErrOut string
   194  		expectedBrowse string
   195  		wantErr        string
   196  		tty            bool
   197  	}{
   198  		{
   199  			name: "nontty web",
   200  			setup: func(opts *CreateOptions, t *testing.T) func() {
   201  				opts.WebMode = true
   202  				opts.HeadBranch = "feature"
   203  				return func() {}
   204  			},
   205  			cmdStubs: func(cs *run.CommandStubber) {
   206  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
   207  			},
   208  			expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1",
   209  		},
   210  		{
   211  			name: "nontty",
   212  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   213  				reg.Register(
   214  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   215  					httpmock.GraphQLMutation(`
   216  					{ "data": { "createPullRequest": { "pullRequest": {
   217  						"URL": "https://github.com/OWNER/REPO/pull/12"
   218  					} } } }`,
   219  						func(input map[string]interface{}) {
   220  							assert.Equal(t, "REPOID", input["repositoryId"])
   221  							assert.Equal(t, "my title", input["title"])
   222  							assert.Equal(t, "my body", input["body"])
   223  							assert.Equal(t, "master", input["baseRefName"])
   224  							assert.Equal(t, "feature", input["headRefName"])
   225  						}))
   226  			},
   227  			setup: func(opts *CreateOptions, t *testing.T) func() {
   228  				opts.TitleProvided = true
   229  				opts.BodyProvided = true
   230  				opts.Title = "my title"
   231  				opts.Body = "my body"
   232  				opts.HeadBranch = "feature"
   233  				return func() {}
   234  			},
   235  			expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
   236  		},
   237  		{
   238  			name: "survey",
   239  			tty:  true,
   240  			setup: func(opts *CreateOptions, t *testing.T) func() {
   241  				opts.TitleProvided = true
   242  				opts.BodyProvided = true
   243  				opts.Title = "my title"
   244  				opts.Body = "my body"
   245  				return func() {}
   246  			},
   247  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   248  				reg.StubRepoResponse("OWNER", "REPO")
   249  				reg.Register(
   250  					httpmock.GraphQL(`query UserCurrent\b`),
   251  					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
   252  				reg.Register(
   253  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   254  					httpmock.GraphQLMutation(`
   255  						{ "data": { "createPullRequest": { "pullRequest": {
   256  							"URL": "https://github.com/OWNER/REPO/pull/12"
   257  						} } } }`, func(input map[string]interface{}) {
   258  						assert.Equal(t, "REPOID", input["repositoryId"].(string))
   259  						assert.Equal(t, "my title", input["title"].(string))
   260  						assert.Equal(t, "my body", input["body"].(string))
   261  						assert.Equal(t, "master", input["baseRefName"].(string))
   262  						assert.Equal(t, "feature", input["headRefName"].(string))
   263  						assert.Equal(t, false, input["draft"].(bool))
   264  					}))
   265  			},
   266  			cmdStubs: func(cs *run.CommandStubber) {
   267  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   268  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
   269  				cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "")
   270  			},
   271  			askStubs: func(as *prompt.AskStubber) {
   272  				as.StubPrompt("Where should we push the 'feature' branch?").AnswerDefault()
   273  			},
   274  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   275  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   276  		},
   277  		{
   278  			name: "no maintainer modify",
   279  			tty:  true,
   280  			setup: func(opts *CreateOptions, t *testing.T) func() {
   281  				opts.TitleProvided = true
   282  				opts.BodyProvided = true
   283  				opts.Title = "my title"
   284  				opts.Body = "my body"
   285  				return func() {}
   286  			},
   287  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   288  				reg.StubRepoResponse("OWNER", "REPO")
   289  				reg.Register(
   290  					httpmock.GraphQL(`query UserCurrent\b`),
   291  					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
   292  				reg.Register(
   293  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   294  					httpmock.GraphQLMutation(`
   295  						{ "data": { "createPullRequest": { "pullRequest": {
   296  							"URL": "https://github.com/OWNER/REPO/pull/12"
   297  						} } } }
   298  						`, func(input map[string]interface{}) {
   299  						assert.Equal(t, false, input["maintainerCanModify"].(bool))
   300  						assert.Equal(t, "REPOID", input["repositoryId"].(string))
   301  						assert.Equal(t, "my title", input["title"].(string))
   302  						assert.Equal(t, "my body", input["body"].(string))
   303  						assert.Equal(t, "master", input["baseRefName"].(string))
   304  						assert.Equal(t, "feature", input["headRefName"].(string))
   305  					}))
   306  			},
   307  			cmdStubs: func(cs *run.CommandStubber) {
   308  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   309  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
   310  				cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "")
   311  			},
   312  			askStubs: func(as *prompt.AskStubber) {
   313  				as.StubPrompt("Where should we push the 'feature' branch?").AnswerDefault()
   314  			},
   315  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   316  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   317  		},
   318  		{
   319  			name: "create fork",
   320  			tty:  true,
   321  			setup: func(opts *CreateOptions, t *testing.T) func() {
   322  				opts.TitleProvided = true
   323  				opts.BodyProvided = true
   324  				opts.Title = "title"
   325  				opts.Body = "body"
   326  				return func() {}
   327  			},
   328  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   329  				reg.StubRepoResponse("OWNER", "REPO")
   330  				reg.Register(
   331  					httpmock.GraphQL(`query UserCurrent\b`),
   332  					httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
   333  				reg.Register(
   334  					httpmock.REST("POST", "repos/OWNER/REPO/forks"),
   335  					httpmock.StatusStringResponse(201, `
   336  						{ "node_id": "NODEID",
   337  						  "name": "REPO",
   338  						  "owner": {"login": "monalisa"}
   339  						}`))
   340  				reg.Register(
   341  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   342  					httpmock.GraphQLMutation(`
   343  						{ "data": { "createPullRequest": { "pullRequest": {
   344  							"URL": "https://github.com/OWNER/REPO/pull/12"
   345  						}}}}`, func(input map[string]interface{}) {
   346  						assert.Equal(t, "REPOID", input["repositoryId"].(string))
   347  						assert.Equal(t, "master", input["baseRefName"].(string))
   348  						assert.Equal(t, "monalisa:feature", input["headRefName"].(string))
   349  					}))
   350  			},
   351  			cmdStubs: func(cs *run.CommandStubber) {
   352  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   353  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
   354  				cs.Register(`git remote add -f fork https://github.com/monalisa/REPO.git`, 0, "")
   355  				cs.Register(`git push --set-upstream fork HEAD:feature`, 0, "")
   356  			},
   357  			askStubs: func(as *prompt.AskStubber) {
   358  				as.StubPrompt("Where should we push the 'feature' branch?").
   359  					AssertOptions([]string{"OWNER/REPO", "Create a fork of OWNER/REPO", "Skip pushing the branch", "Cancel"}).
   360  					AnswerWith("Create a fork of OWNER/REPO")
   361  			},
   362  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   363  			expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n",
   364  		},
   365  		{
   366  			name: "pushed to non base repo",
   367  			tty:  true,
   368  			setup: func(opts *CreateOptions, t *testing.T) func() {
   369  				opts.TitleProvided = true
   370  				opts.BodyProvided = true
   371  				opts.Title = "title"
   372  				opts.Body = "body"
   373  				opts.Remotes = func() (context.Remotes, error) {
   374  					return context.Remotes{
   375  						{
   376  							Remote: &git.Remote{
   377  								Name:     "upstream",
   378  								Resolved: "base",
   379  							},
   380  							Repo: ghrepo.New("OWNER", "REPO"),
   381  						},
   382  						{
   383  							Remote: &git.Remote{
   384  								Name:     "origin",
   385  								Resolved: "base",
   386  							},
   387  							Repo: ghrepo.New("monalisa", "REPO"),
   388  						},
   389  					}, nil
   390  				}
   391  				return func() {}
   392  			},
   393  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   394  				reg.Register(
   395  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   396  					httpmock.GraphQLMutation(`
   397  						{ "data": { "createPullRequest": { "pullRequest": {
   398  							"URL": "https://github.com/OWNER/REPO/pull/12"
   399  						} } } }`, func(input map[string]interface{}) {
   400  						assert.Equal(t, "REPOID", input["repositoryId"].(string))
   401  						assert.Equal(t, "master", input["baseRefName"].(string))
   402  						assert.Equal(t, "monalisa:feature", input["headRefName"].(string))
   403  					}))
   404  			},
   405  			cmdStubs: func(cs *run.CommandStubber) {
   406  				cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 1, "") // determineTrackingBranch
   407  				cs.Register("git show-ref --verify", 0, heredoc.Doc(`
   408  		deadbeef HEAD
   409  		deadb00f refs/remotes/upstream/feature
   410  		deadbeef refs/remotes/origin/feature`)) // determineTrackingBranch
   411  			},
   412  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   413  			expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n",
   414  		},
   415  		{
   416  			name: "pushed to different branch name",
   417  			tty:  true,
   418  			setup: func(opts *CreateOptions, t *testing.T) func() {
   419  				opts.TitleProvided = true
   420  				opts.BodyProvided = true
   421  				opts.Title = "title"
   422  				opts.Body = "body"
   423  				return func() {}
   424  			},
   425  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   426  				reg.Register(
   427  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   428  					httpmock.GraphQLMutation(`
   429  		{ "data": { "createPullRequest": { "pullRequest": {
   430  			"URL": "https://github.com/OWNER/REPO/pull/12"
   431  		} } } }
   432  		`, func(input map[string]interface{}) {
   433  						assert.Equal(t, "REPOID", input["repositoryId"].(string))
   434  						assert.Equal(t, "master", input["baseRefName"].(string))
   435  						assert.Equal(t, "my-feat2", input["headRefName"].(string))
   436  					}))
   437  			},
   438  			cmdStubs: func(cs *run.CommandStubber) {
   439  				cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(`
   440  		branch.feature.remote origin
   441  		branch.feature.merge refs/heads/my-feat2
   442  	`)) // determineTrackingBranch
   443  				cs.Register("git show-ref --verify", 0, heredoc.Doc(`
   444  		deadbeef HEAD
   445  		deadbeef refs/remotes/origin/my-feat2
   446  	`)) // determineTrackingBranch
   447  			},
   448  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   449  			expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n",
   450  		},
   451  		{
   452  			name: "non legacy template",
   453  			tty:  true,
   454  			setup: func(opts *CreateOptions, t *testing.T) func() {
   455  				opts.TitleProvided = true
   456  				opts.Title = "my title"
   457  				opts.HeadBranch = "feature"
   458  				opts.RootDirOverride = "./fixtures/repoWithNonLegacyPRTemplates"
   459  				return func() {}
   460  			},
   461  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   462  				reg.Register(
   463  					httpmock.GraphQL(`query PullRequestTemplates\b`),
   464  					httpmock.StringResponse(`
   465  				{ "data": { "repository": { "pullRequestTemplates": [
   466  					{ "filename": "template1",
   467  					  "body": "this is a bug" },
   468  					{ "filename": "template2",
   469  					  "body": "this is a enhancement" }
   470  				] } } }`))
   471  				reg.Register(
   472  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   473  					httpmock.GraphQLMutation(`
   474  			{ "data": { "createPullRequest": { "pullRequest": {
   475  				"URL": "https://github.com/OWNER/REPO/pull/12"
   476  			} } } }
   477  			`, func(input map[string]interface{}) {
   478  						assert.Equal(t, "my title", input["title"].(string))
   479  						assert.Equal(t, "- commit 1\n- commit 0\n\nthis is a bug", input["body"].(string))
   480  					}))
   481  			},
   482  			cmdStubs: func(cs *run.CommandStubber) {
   483  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "1234567890,commit 0\n2345678901,commit 1")
   484  			},
   485  			askStubs: func(as *prompt.AskStubber) {
   486  				as.StubPrompt("Choose a template").
   487  					AssertOptions([]string{"template1", "template2", "Open a blank pull request"}).
   488  					AnswerWith("template1")
   489  				as.StubPrompt("Body").AnswerDefault()
   490  				as.StubPrompt("What's next?").
   491  					AssertOptions([]string{"Submit", "Submit as draft", "Continue in browser", "Add metadata", "Cancel"}).
   492  					AnswerDefault()
   493  			},
   494  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   495  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   496  		},
   497  		{
   498  			name: "metadata",
   499  			tty:  true,
   500  			setup: func(opts *CreateOptions, t *testing.T) func() {
   501  				opts.TitleProvided = true
   502  				opts.Title = "TITLE"
   503  				opts.BodyProvided = true
   504  				opts.Body = "BODY"
   505  				opts.HeadBranch = "feature"
   506  				opts.Assignees = []string{"monalisa"}
   507  				opts.Labels = []string{"bug", "todo"}
   508  				opts.Projects = []string{"roadmap"}
   509  				opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"}
   510  				opts.Milestone = "big one.oh"
   511  				return func() {}
   512  			},
   513  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   514  				reg.Register(
   515  					httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
   516  					httpmock.StringResponse(`
   517  				{ "data": {
   518  					"u000": { "login": "MonaLisa", "id": "MONAID" },
   519  					"u001": { "login": "hubot", "id": "HUBOTID" },
   520  					"repository": {
   521  						"l000": { "name": "bug", "id": "BUGID" },
   522  						"l001": { "name": "TODO", "id": "TODOID" }
   523  					},
   524  					"organization": {
   525  						"t000": { "slug": "core", "id": "COREID" },
   526  						"t001": { "slug": "robots", "id": "ROBOTID" }
   527  					}
   528  				} }
   529  				`))
   530  				reg.Register(
   531  					httpmock.GraphQL(`query RepositoryMilestoneList\b`),
   532  					httpmock.StringResponse(`
   533  				{ "data": { "repository": { "milestones": {
   534  					"nodes": [
   535  						{ "title": "GA", "id": "GAID" },
   536  						{ "title": "Big One.oh", "id": "BIGONEID" }
   537  					],
   538  					"pageInfo": { "hasNextPage": false }
   539  				} } } }
   540  				`))
   541  				reg.Register(
   542  					httpmock.GraphQL(`query RepositoryProjectList\b`),
   543  					httpmock.StringResponse(`
   544  				{ "data": { "repository": { "projects": {
   545  					"nodes": [
   546  						{ "name": "Cleanup", "id": "CLEANUPID" },
   547  						{ "name": "Roadmap", "id": "ROADMAPID" }
   548  					],
   549  					"pageInfo": { "hasNextPage": false }
   550  				} } } }
   551  				`))
   552  				reg.Register(
   553  					httpmock.GraphQL(`query OrganizationProjectList\b`),
   554  					httpmock.StringResponse(`
   555  				{ "data": { "organization": { "projects": {
   556  					"nodes": [],
   557  					"pageInfo": { "hasNextPage": false }
   558  				} } } }
   559  				`))
   560  				reg.Register(
   561  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   562  					httpmock.GraphQLMutation(`
   563  				{ "data": { "createPullRequest": { "pullRequest": {
   564  					"id": "NEWPULLID",
   565  					"URL": "https://github.com/OWNER/REPO/pull/12"
   566  				} } } }
   567  			`, func(inputs map[string]interface{}) {
   568  						assert.Equal(t, "TITLE", inputs["title"])
   569  						assert.Equal(t, "BODY", inputs["body"])
   570  						if v, ok := inputs["assigneeIds"]; ok {
   571  							t.Errorf("did not expect assigneeIds: %v", v)
   572  						}
   573  						if v, ok := inputs["userIds"]; ok {
   574  							t.Errorf("did not expect userIds: %v", v)
   575  						}
   576  					}))
   577  				reg.Register(
   578  					httpmock.GraphQL(`mutation PullRequestCreateMetadata\b`),
   579  					httpmock.GraphQLMutation(`
   580  				{ "data": { "updatePullRequest": {
   581  					"clientMutationId": ""
   582  				} } }
   583  			`, func(inputs map[string]interface{}) {
   584  						assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
   585  						assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
   586  						assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
   587  						assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
   588  						assert.Equal(t, "BIGONEID", inputs["milestoneId"])
   589  					}))
   590  				reg.Register(
   591  					httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`),
   592  					httpmock.GraphQLMutation(`
   593  				{ "data": { "requestReviews": {
   594  					"clientMutationId": ""
   595  				} } }
   596  			`, func(inputs map[string]interface{}) {
   597  						assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
   598  						assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"])
   599  						assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"])
   600  						assert.Equal(t, true, inputs["union"])
   601  					}))
   602  			},
   603  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   604  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   605  		},
   606  		{
   607  			name: "already exists",
   608  			tty:  true,
   609  			setup: func(opts *CreateOptions, t *testing.T) func() {
   610  				opts.TitleProvided = true
   611  				opts.BodyProvided = true
   612  				opts.Title = "title"
   613  				opts.Body = "body"
   614  				opts.HeadBranch = "feature"
   615  				opts.Finder = shared.NewMockFinder("feature", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/123"}, ghrepo.New("OWNER", "REPO"))
   616  				return func() {}
   617  			},
   618  			wantErr: "a pull request for branch \"feature\" into branch \"master\" already exists:\nhttps://github.com/OWNER/REPO/pull/123",
   619  		},
   620  		{
   621  			name: "web",
   622  			tty:  true,
   623  			setup: func(opts *CreateOptions, t *testing.T) func() {
   624  				opts.WebMode = true
   625  				return func() {}
   626  			},
   627  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   628  				reg.StubRepoResponse("OWNER", "REPO")
   629  				reg.Register(
   630  					httpmock.GraphQL(`query UserCurrent\b`),
   631  					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
   632  			},
   633  			cmdStubs: func(cs *run.CommandStubber) {
   634  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   635  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
   636  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
   637  				cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "")
   638  			},
   639  			askStubs: func(as *prompt.AskStubber) {
   640  				as.StubPrompt("Where should we push the 'feature' branch?").
   641  					AssertOptions([]string{"OWNER/REPO", "Skip pushing the branch", "Cancel"}).
   642  					AnswerDefault()
   643  			},
   644  			expectedErrOut: "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n",
   645  			expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1",
   646  		},
   647  		{
   648  			name: "web project",
   649  			tty:  true,
   650  			setup: func(opts *CreateOptions, t *testing.T) func() {
   651  				opts.WebMode = true
   652  				opts.Projects = []string{"Triage"}
   653  				return func() {}
   654  			},
   655  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   656  				reg.StubRepoResponse("OWNER", "REPO")
   657  				reg.Register(
   658  					httpmock.GraphQL(`query UserCurrent\b`),
   659  					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
   660  				reg.Register(
   661  					httpmock.GraphQL(`query RepositoryProjectList\b`),
   662  					httpmock.StringResponse(`
   663  			{ "data": { "repository": { "projects": {
   664  				"nodes": [
   665  					{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }
   666  				],
   667  				"pageInfo": { "hasNextPage": false }
   668  			} } } }
   669  			`))
   670  				reg.Register(
   671  					httpmock.GraphQL(`query OrganizationProjectList\b`),
   672  					httpmock.StringResponse(`
   673  			{ "data": { "organization": { "projects": {
   674  				"nodes": [
   675  					{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1"  }
   676  				],
   677  				"pageInfo": { "hasNextPage": false }
   678  			} } } }
   679  			`))
   680  			},
   681  			cmdStubs: func(cs *run.CommandStubber) {
   682  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   683  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
   684  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
   685  				cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "")
   686  
   687  			},
   688  			askStubs: func(as *prompt.AskStubber) {
   689  				as.StubPrompt("Where should we push the 'feature' branch?").AnswerDefault()
   690  			},
   691  			expectedErrOut: "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n",
   692  			expectedBrowse: "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1&projects=ORG%2F1",
   693  		},
   694  		{
   695  			name: "draft",
   696  			tty:  true,
   697  			setup: func(opts *CreateOptions, t *testing.T) func() {
   698  				opts.TitleProvided = true
   699  				opts.Title = "my title"
   700  				opts.HeadBranch = "feature"
   701  				return func() {}
   702  			},
   703  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   704  				reg.Register(
   705  					httpmock.GraphQL(`query PullRequestTemplates\b`),
   706  					httpmock.StringResponse(`
   707  			{ "data": { "repository": { "pullRequestTemplates": [
   708  				{ "filename": "template1",
   709  				  "body": "this is a bug" },
   710  				{ "filename": "template2",
   711  				  "body": "this is a enhancement" }
   712  			] } } }`),
   713  				)
   714  				reg.Register(
   715  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   716  					httpmock.GraphQLMutation(`
   717  		{ "data": { "createPullRequest": { "pullRequest": {
   718  			"URL": "https://github.com/OWNER/REPO/pull/12"
   719  		} } } }
   720  		`, func(input map[string]interface{}) {
   721  						assert.Equal(t, true, input["draft"].(bool))
   722  					}))
   723  			},
   724  			cmdStubs: func(cs *run.CommandStubber) {
   725  				cs.Register(`git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry origin/master...feature`, 0, "")
   726  				cs.Register(`git rev-parse --show-toplevel`, 0, "")
   727  			},
   728  			askStubs: func(as *prompt.AskStubber) {
   729  				as.StubPrompt("Choose a template").AnswerDefault()
   730  				as.StubPrompt("Body").AnswerDefault()
   731  				as.StubPrompt("What's next?").
   732  					AssertOptions([]string{"Submit", "Submit as draft", "Continue in browser", "Add metadata", "Cancel"}).
   733  					AnswerWith("Submit as draft")
   734  			},
   735  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   736  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   737  		},
   738  		{
   739  			name: "recover",
   740  			tty:  true,
   741  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   742  				reg.Register(
   743  					httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
   744  					httpmock.StringResponse(`
   745  		{ "data": {
   746  			"u000": { "login": "jillValentine", "id": "JILLID" },
   747  			"repository": {},
   748  			"organization": {}
   749  		} }
   750  		`))
   751  				reg.Register(
   752  					httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`),
   753  					httpmock.GraphQLMutation(`
   754  		{ "data": { "requestReviews": {
   755  			"clientMutationId": ""
   756  		} } }
   757  	`, func(inputs map[string]interface{}) {
   758  						assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"])
   759  					}))
   760  				reg.Register(
   761  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   762  					httpmock.GraphQLMutation(`
   763  		{ "data": { "createPullRequest": { "pullRequest": {
   764  			"URL": "https://github.com/OWNER/REPO/pull/12"
   765  		} } } }
   766  		`, func(input map[string]interface{}) {
   767  						assert.Equal(t, "recovered title", input["title"].(string))
   768  						assert.Equal(t, "recovered body", input["body"].(string))
   769  					}))
   770  			},
   771  			cmdStubs: func(cs *run.CommandStubber) {
   772  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
   773  			},
   774  			askStubs: func(as *prompt.AskStubber) {
   775  				as.StubPrompt("Title").AnswerDefault()
   776  				as.StubPrompt("Body").AnswerDefault()
   777  				as.StubPrompt("What's next?").AnswerDefault()
   778  			},
   779  			setup: func(opts *CreateOptions, t *testing.T) func() {
   780  				tmpfile, err := os.CreateTemp(t.TempDir(), "testrecover*")
   781  				assert.NoError(t, err)
   782  				state := shared.IssueMetadataState{
   783  					Title:     "recovered title",
   784  					Body:      "recovered body",
   785  					Reviewers: []string{"jillValentine"},
   786  				}
   787  				data, err := json.Marshal(state)
   788  				assert.NoError(t, err)
   789  				_, err = tmpfile.Write(data)
   790  				assert.NoError(t, err)
   791  
   792  				opts.RecoverFile = tmpfile.Name()
   793  				opts.HeadBranch = "feature"
   794  				return func() { tmpfile.Close() }
   795  			},
   796  			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
   797  			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
   798  		},
   799  		{
   800  			name: "web long URL",
   801  			cmdStubs: func(cs *run.CommandStubber) {
   802  				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
   803  			},
   804  			setup: func(opts *CreateOptions, t *testing.T) func() {
   805  				longBody := make([]byte, 9216)
   806  				opts.Body = string(longBody)
   807  				opts.BodyProvided = true
   808  				opts.WebMode = true
   809  				opts.HeadBranch = "feature"
   810  				return func() {}
   811  			},
   812  			wantErr: "cannot open in browser: maximum URL length exceeded",
   813  		},
   814  		{
   815  			name: "no local git repo",
   816  			setup: func(opts *CreateOptions, t *testing.T) func() {
   817  				opts.Title = "My PR"
   818  				opts.TitleProvided = true
   819  				opts.Body = ""
   820  				opts.BodyProvided = true
   821  				opts.HeadBranch = "feature"
   822  				opts.RepoOverride = "OWNER/REPO"
   823  				opts.Remotes = func() (context.Remotes, error) {
   824  					return nil, errors.New("not a git repository")
   825  				}
   826  				return func() {}
   827  			},
   828  			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
   829  				reg.Register(
   830  					httpmock.GraphQL(`mutation PullRequestCreate\b`),
   831  					httpmock.StringResponse(`
   832  						{ "data": { "createPullRequest": { "pullRequest": {
   833  							"URL": "https://github.com/OWNER/REPO/pull/12"
   834  						} } } }
   835  					`))
   836  			},
   837  			expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
   838  		},
   839  	}
   840  	for _, tt := range tests {
   841  		t.Run(tt.name, func(t *testing.T) {
   842  			branch := "feature"
   843  
   844  			reg := &httpmock.Registry{}
   845  			reg.StubRepoInfoResponse("OWNER", "REPO", "master")
   846  			defer reg.Verify(t)
   847  			if tt.httpStubs != nil {
   848  				tt.httpStubs(reg, t)
   849  			}
   850  
   851  			//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
   852  			ask, cleanupAsk := prompt.InitAskStubber()
   853  			defer cleanupAsk()
   854  			if tt.askStubs != nil {
   855  				tt.askStubs(ask)
   856  			}
   857  
   858  			cs, cmdTeardown := run.Stub()
   859  			defer cmdTeardown(t)
   860  			cs.Register(`git status --porcelain`, 0, "")
   861  
   862  			if tt.cmdStubs != nil {
   863  				tt.cmdStubs(cs)
   864  			}
   865  
   866  			opts := CreateOptions{}
   867  
   868  			ios, _, stdout, stderr := iostreams.Test()
   869  			// TODO do i need to bother with this
   870  			ios.SetStdoutTTY(tt.tty)
   871  			ios.SetStdinTTY(tt.tty)
   872  			ios.SetStderrTTY(tt.tty)
   873  			browser := &browser.Stub{}
   874  			opts.IO = ios
   875  			opts.Browser = browser
   876  			opts.HttpClient = func() (*http.Client, error) {
   877  				return &http.Client{Transport: reg}, nil
   878  			}
   879  			opts.Config = func() (config.Config, error) {
   880  				return config.NewBlankConfig(), nil
   881  			}
   882  			opts.Remotes = func() (context.Remotes, error) {
   883  				return context.Remotes{
   884  					{
   885  						Remote: &git.Remote{
   886  							Name:     "origin",
   887  							Resolved: "base",
   888  						},
   889  						Repo: ghrepo.New("OWNER", "REPO"),
   890  					},
   891  				}, nil
   892  			}
   893  			opts.Branch = func() (string, error) {
   894  				return branch, nil
   895  			}
   896  			opts.Finder = shared.NewMockFinder(branch, nil, nil)
   897  			opts.GitClient = &git.Client{GitPath: "some/path/git"}
   898  			cleanSetup := func() {}
   899  			if tt.setup != nil {
   900  				cleanSetup = tt.setup(&opts, t)
   901  			}
   902  			defer cleanSetup()
   903  
   904  			err := createRun(&opts)
   905  			output := &test.CmdOut{
   906  				OutBuf:     stdout,
   907  				ErrBuf:     stderr,
   908  				BrowsedURL: browser.BrowsedURL(),
   909  			}
   910  			if tt.wantErr != "" {
   911  				assert.EqualError(t, err, tt.wantErr)
   912  			} else {
   913  				assert.NoError(t, err)
   914  				assert.Equal(t, tt.expectedOut, output.String())
   915  				assert.Equal(t, tt.expectedErrOut, output.Stderr())
   916  				assert.Equal(t, tt.expectedBrowse, output.BrowsedURL)
   917  			}
   918  		})
   919  	}
   920  }
   921  
   922  func Test_determineTrackingBranch(t *testing.T) {
   923  	tests := []struct {
   924  		name     string
   925  		cmdStubs func(*run.CommandStubber)
   926  		remotes  context.Remotes
   927  		assert   func(ref *git.TrackingRef, t *testing.T)
   928  	}{
   929  		{
   930  			name: "empty",
   931  			cmdStubs: func(cs *run.CommandStubber) {
   932  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   933  				cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD")
   934  			},
   935  			assert: func(ref *git.TrackingRef, t *testing.T) {
   936  				assert.Nil(t, ref)
   937  			},
   938  		},
   939  		{
   940  			name: "no match",
   941  			cmdStubs: func(cs *run.CommandStubber) {
   942  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   943  				cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature refs/remotes/upstream/feature", 0, "abc HEAD\nbca refs/remotes/origin/feature")
   944  			},
   945  			remotes: context.Remotes{
   946  				&context.Remote{
   947  					Remote: &git.Remote{Name: "origin"},
   948  					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
   949  				},
   950  				&context.Remote{
   951  					Remote: &git.Remote{Name: "upstream"},
   952  					Repo:   ghrepo.New("octocat", "Spoon-Knife"),
   953  				},
   954  			},
   955  			assert: func(ref *git.TrackingRef, t *testing.T) {
   956  				assert.Nil(t, ref)
   957  			},
   958  		},
   959  		{
   960  			name: "match",
   961  			cmdStubs: func(cs *run.CommandStubber) {
   962  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
   963  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature refs/remotes/upstream/feature$`, 0, heredoc.Doc(`
   964  		deadbeef HEAD
   965  		deadb00f refs/remotes/origin/feature
   966  		deadbeef refs/remotes/upstream/feature
   967  	`))
   968  			},
   969  			remotes: context.Remotes{
   970  				&context.Remote{
   971  					Remote: &git.Remote{Name: "origin"},
   972  					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
   973  				},
   974  				&context.Remote{
   975  					Remote: &git.Remote{Name: "upstream"},
   976  					Repo:   ghrepo.New("octocat", "Spoon-Knife"),
   977  				},
   978  			},
   979  			assert: func(ref *git.TrackingRef, t *testing.T) {
   980  				assert.Equal(t, "upstream", ref.RemoteName)
   981  				assert.Equal(t, "feature", ref.BranchName)
   982  			},
   983  		},
   984  		{
   985  			name: "respect tracking config",
   986  			cmdStubs: func(cs *run.CommandStubber) {
   987  				cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, heredoc.Doc(`
   988  		branch.feature.remote origin
   989  		branch.feature.merge refs/heads/great-feat
   990  	`))
   991  				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(`
   992  		deadbeef HEAD
   993  		deadb00f refs/remotes/origin/feature
   994  	`))
   995  			},
   996  			remotes: context.Remotes{
   997  				&context.Remote{
   998  					Remote: &git.Remote{Name: "origin"},
   999  					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
  1000  				},
  1001  			},
  1002  			assert: func(ref *git.TrackingRef, t *testing.T) {
  1003  				assert.Nil(t, ref)
  1004  			},
  1005  		},
  1006  	}
  1007  	for _, tt := range tests {
  1008  		t.Run(tt.name, func(t *testing.T) {
  1009  			cs, cmdTeardown := run.Stub()
  1010  			defer cmdTeardown(t)
  1011  
  1012  			tt.cmdStubs(cs)
  1013  
  1014  			gitClient := &git.Client{GitPath: "some/path/git"}
  1015  			ref := determineTrackingBranch(gitClient, tt.remotes, "feature")
  1016  			tt.assert(ref, t)
  1017  		})
  1018  	}
  1019  }
  1020  
  1021  func Test_generateCompareURL(t *testing.T) {
  1022  	tests := []struct {
  1023  		name    string
  1024  		ctx     CreateContext
  1025  		state   shared.IssueMetadataState
  1026  		want    string
  1027  		wantErr bool
  1028  	}{
  1029  		{
  1030  			name: "basic",
  1031  			ctx: CreateContext{
  1032  				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
  1033  				BaseBranch:      "main",
  1034  				HeadBranchLabel: "feature",
  1035  			},
  1036  			want:    "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1",
  1037  			wantErr: false,
  1038  		},
  1039  		{
  1040  			name: "with labels",
  1041  			ctx: CreateContext{
  1042  				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
  1043  				BaseBranch:      "a",
  1044  				HeadBranchLabel: "b",
  1045  			},
  1046  			state: shared.IssueMetadataState{
  1047  				Labels: []string{"one", "two three"},
  1048  			},
  1049  			want:    "https://github.com/OWNER/REPO/compare/a...b?body=&expand=1&labels=one%2Ctwo+three",
  1050  			wantErr: false,
  1051  		},
  1052  		{
  1053  			name: "'/'s in branch names/labels are percent-encoded",
  1054  			ctx: CreateContext{
  1055  				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
  1056  				BaseBranch:      "main/trunk",
  1057  				HeadBranchLabel: "owner:feature",
  1058  			},
  1059  			want:    "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:feature?body=&expand=1",
  1060  			wantErr: false,
  1061  		},
  1062  		{
  1063  			name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ",
  1064  			/*
  1065  					- 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
  1066  					- !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[]
  1067  					- : is GitHub separator between a fork name and a branch name
  1068  				    - See https://github.com/golang/go/issues/27559.
  1069  			*/
  1070  			ctx: CreateContext{
  1071  				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
  1072  				BaseBranch:      "main/trunk",
  1073  				HeadBranchLabel: "owner:!$&'()+,;=@",
  1074  			},
  1075  			want:    "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
  1076  			wantErr: false,
  1077  		},
  1078  	}
  1079  	for _, tt := range tests {
  1080  		t.Run(tt.name, func(t *testing.T) {
  1081  			got, err := generateCompareURL(tt.ctx, tt.state)
  1082  			if (err != nil) != tt.wantErr {
  1083  				t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr)
  1084  				return
  1085  			}
  1086  			if got != tt.want {
  1087  				t.Errorf("generateCompareURL() = %v, want %v", got, tt.want)
  1088  			}
  1089  		})
  1090  	}
  1091  }