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 }