github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/issue/create/create_test.go (about) 1 package create 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "path/filepath" 10 "strings" 11 "testing" 12 13 "github.com/MakeNowJust/heredoc" 14 "github.com/cli/cli/internal/config" 15 "github.com/cli/cli/internal/ghrepo" 16 "github.com/cli/cli/internal/run" 17 prShared "github.com/cli/cli/pkg/cmd/pr/shared" 18 "github.com/cli/cli/pkg/cmdutil" 19 "github.com/cli/cli/pkg/httpmock" 20 "github.com/cli/cli/pkg/iostreams" 21 "github.com/cli/cli/pkg/prompt" 22 "github.com/cli/cli/test" 23 "github.com/google/shlex" 24 "github.com/stretchr/testify/assert" 25 "github.com/stretchr/testify/require" 26 ) 27 28 func TestNewCmdCreate(t *testing.T) { 29 tmpFile := filepath.Join(t.TempDir(), "my-body.md") 30 err := ioutil.WriteFile(tmpFile, []byte("a body from file"), 0600) 31 require.NoError(t, err) 32 33 tests := []struct { 34 name string 35 tty bool 36 stdin string 37 cli string 38 wantsErr bool 39 wantsOpts CreateOptions 40 }{ 41 { 42 name: "empty non-tty", 43 tty: false, 44 cli: "", 45 wantsErr: true, 46 }, 47 { 48 name: "only title non-tty", 49 tty: false, 50 cli: "-t mytitle", 51 wantsErr: true, 52 }, 53 { 54 name: "empty tty", 55 tty: true, 56 cli: "", 57 wantsErr: false, 58 wantsOpts: CreateOptions{ 59 Title: "", 60 Body: "", 61 RecoverFile: "", 62 WebMode: false, 63 Interactive: true, 64 }, 65 }, 66 { 67 name: "body from stdin", 68 tty: false, 69 stdin: "this is on standard input", 70 cli: "-t mytitle -F -", 71 wantsErr: false, 72 wantsOpts: CreateOptions{ 73 Title: "mytitle", 74 Body: "this is on standard input", 75 RecoverFile: "", 76 WebMode: false, 77 Interactive: false, 78 }, 79 }, 80 { 81 name: "body from file", 82 tty: false, 83 cli: fmt.Sprintf("-t mytitle -F '%s'", tmpFile), 84 wantsErr: false, 85 wantsOpts: CreateOptions{ 86 Title: "mytitle", 87 Body: "a body from file", 88 RecoverFile: "", 89 WebMode: false, 90 Interactive: false, 91 }, 92 }, 93 } 94 for _, tt := range tests { 95 t.Run(tt.name, func(t *testing.T) { 96 io, stdin, stdout, stderr := iostreams.Test() 97 if tt.stdin != "" { 98 _, _ = stdin.WriteString(tt.stdin) 99 } else if tt.tty { 100 io.SetStdinTTY(true) 101 io.SetStdoutTTY(true) 102 } 103 104 f := &cmdutil.Factory{ 105 IOStreams: io, 106 } 107 108 var opts *CreateOptions 109 cmd := NewCmdCreate(f, func(o *CreateOptions) error { 110 opts = o 111 return nil 112 }) 113 114 args, err := shlex.Split(tt.cli) 115 require.NoError(t, err) 116 cmd.SetArgs(args) 117 cmd.SetOut(ioutil.Discard) 118 cmd.SetErr(ioutil.Discard) 119 _, err = cmd.ExecuteC() 120 if tt.wantsErr { 121 assert.Error(t, err) 122 return 123 } else { 124 require.NoError(t, err) 125 } 126 127 assert.Equal(t, "", stdout.String()) 128 assert.Equal(t, "", stderr.String()) 129 130 assert.Equal(t, tt.wantsOpts.Body, opts.Body) 131 assert.Equal(t, tt.wantsOpts.Title, opts.Title) 132 assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile) 133 assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) 134 assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive) 135 }) 136 } 137 } 138 139 func Test_createRun(t *testing.T) { 140 tests := []struct { 141 name string 142 opts CreateOptions 143 httpStubs func(*httpmock.Registry) 144 wantsStdout string 145 wantsStderr string 146 wantsBrowse string 147 wantsErr string 148 }{ 149 { 150 name: "no args", 151 opts: CreateOptions{ 152 WebMode: true, 153 }, 154 wantsBrowse: "https://github.com/OWNER/REPO/issues/new", 155 wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", 156 }, 157 { 158 name: "title and body", 159 opts: CreateOptions{ 160 WebMode: true, 161 Title: "myissue", 162 Body: "hello cli", 163 }, 164 wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=hello+cli&title=myissue", 165 wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", 166 }, 167 { 168 name: "assignee", 169 opts: CreateOptions{ 170 WebMode: true, 171 Assignees: []string{"monalisa"}, 172 }, 173 wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa&body=", 174 wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", 175 }, 176 { 177 name: "@me", 178 opts: CreateOptions{ 179 WebMode: true, 180 Assignees: []string{"@me"}, 181 }, 182 httpStubs: func(r *httpmock.Registry) { 183 r.Register( 184 httpmock.GraphQL(`query UserCurrent\b`), 185 httpmock.StringResponse(` 186 { "data": { 187 "viewer": { "login": "MonaLisa" } 188 } }`)) 189 }, 190 wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa&body=", 191 wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", 192 }, 193 { 194 name: "project", 195 opts: CreateOptions{ 196 WebMode: true, 197 Projects: []string{"cleanup"}, 198 }, 199 httpStubs: func(r *httpmock.Registry) { 200 r.Register( 201 httpmock.GraphQL(`query RepositoryProjectList\b`), 202 httpmock.StringResponse(` 203 { "data": { "repository": { "projects": { 204 "nodes": [ 205 { "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" } 206 ], 207 "pageInfo": { "hasNextPage": false } 208 } } } }`)) 209 r.Register( 210 httpmock.GraphQL(`query OrganizationProjectList\b`), 211 httpmock.StringResponse(` 212 { "data": { "organization": { "projects": { 213 "nodes": [ 214 { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } 215 ], 216 "pageInfo": { "hasNextPage": false } 217 } } } }`)) 218 }, 219 wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1", 220 wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", 221 }, 222 { 223 name: "has templates", 224 opts: CreateOptions{ 225 WebMode: true, 226 }, 227 httpStubs: func(r *httpmock.Registry) { 228 r.Register( 229 httpmock.GraphQL(`query IssueTemplates\b`), 230 httpmock.StringResponse(` 231 { "data": { "repository": { "issueTemplates": [ 232 { "name": "Bug report", 233 "body": "Does not work :((" }, 234 { "name": "Submit a request", 235 "body": "I have a suggestion for an enhancement" } 236 ] } } }`), 237 ) 238 }, 239 wantsBrowse: "https://github.com/OWNER/REPO/issues/new/choose", 240 wantsStderr: "Opening github.com/OWNER/REPO/issues/new/choose in your browser.\n", 241 }, 242 { 243 name: "too long body", 244 opts: CreateOptions{ 245 WebMode: true, 246 Body: strings.Repeat("A", 9216), 247 }, 248 wantsErr: "cannot open in browser: maximum URL length exceeded", 249 }, 250 } 251 for _, tt := range tests { 252 t.Run(tt.name, func(t *testing.T) { 253 httpReg := &httpmock.Registry{} 254 defer httpReg.Verify(t) 255 if tt.httpStubs != nil { 256 tt.httpStubs(httpReg) 257 } 258 259 io, _, stdout, stderr := iostreams.Test() 260 io.SetStdoutTTY(true) 261 opts := &tt.opts 262 opts.IO = io 263 opts.HttpClient = func() (*http.Client, error) { 264 return &http.Client{Transport: httpReg}, nil 265 } 266 opts.BaseRepo = func() (ghrepo.Interface, error) { 267 return ghrepo.New("OWNER", "REPO"), nil 268 } 269 browser := &cmdutil.TestBrowser{} 270 opts.Browser = browser 271 272 err := createRun(opts) 273 if tt.wantsErr == "" { 274 require.NoError(t, err) 275 } else { 276 assert.EqualError(t, err, tt.wantsErr) 277 return 278 } 279 280 assert.Equal(t, tt.wantsStdout, stdout.String()) 281 assert.Equal(t, tt.wantsStderr, stderr.String()) 282 browser.Verify(t, tt.wantsBrowse) 283 }) 284 } 285 } 286 287 /*** LEGACY TESTS ***/ 288 289 func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { 290 return runCommandWithRootDirOverridden(rt, isTTY, cli, "") 291 } 292 293 func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli string, rootDir string) (*test.CmdOut, error) { 294 io, _, stdout, stderr := iostreams.Test() 295 io.SetStdoutTTY(isTTY) 296 io.SetStdinTTY(isTTY) 297 io.SetStderrTTY(isTTY) 298 299 browser := &cmdutil.TestBrowser{} 300 factory := &cmdutil.Factory{ 301 IOStreams: io, 302 HttpClient: func() (*http.Client, error) { 303 return &http.Client{Transport: rt}, nil 304 }, 305 Config: func() (config.Config, error) { 306 return config.NewBlankConfig(), nil 307 }, 308 BaseRepo: func() (ghrepo.Interface, error) { 309 return ghrepo.New("OWNER", "REPO"), nil 310 }, 311 Browser: browser, 312 } 313 314 cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { 315 opts.RootDirOverride = rootDir 316 return createRun(opts) 317 }) 318 319 argv, err := shlex.Split(cli) 320 if err != nil { 321 return nil, err 322 } 323 cmd.SetArgs(argv) 324 325 cmd.SetIn(&bytes.Buffer{}) 326 cmd.SetOut(ioutil.Discard) 327 cmd.SetErr(ioutil.Discard) 328 329 _, err = cmd.ExecuteC() 330 return &test.CmdOut{ 331 OutBuf: stdout, 332 ErrBuf: stderr, 333 BrowsedURL: browser.BrowsedURL(), 334 }, err 335 } 336 337 func TestIssueCreate(t *testing.T) { 338 http := &httpmock.Registry{} 339 defer http.Verify(t) 340 341 http.Register( 342 httpmock.GraphQL(`query RepositoryInfo\b`), 343 httpmock.StringResponse(` 344 { "data": { "repository": { 345 "id": "REPOID", 346 "hasIssuesEnabled": true 347 } } }`), 348 ) 349 http.Register( 350 httpmock.GraphQL(`mutation IssueCreate\b`), 351 httpmock.GraphQLMutation(` 352 { "data": { "createIssue": { "issue": { 353 "URL": "https://github.com/OWNER/REPO/issues/12" 354 } } } }`, 355 func(inputs map[string]interface{}) { 356 assert.Equal(t, inputs["repositoryId"], "REPOID") 357 assert.Equal(t, inputs["title"], "hello") 358 assert.Equal(t, inputs["body"], "cash rules everything around me") 359 }), 360 ) 361 362 output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`) 363 if err != nil { 364 t.Errorf("error running command `issue create`: %v", err) 365 } 366 367 assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) 368 } 369 370 func TestIssueCreate_recover(t *testing.T) { 371 http := &httpmock.Registry{} 372 defer http.Verify(t) 373 374 http.Register( 375 httpmock.GraphQL(`query RepositoryInfo\b`), 376 httpmock.StringResponse(` 377 { "data": { "repository": { 378 "id": "REPOID", 379 "hasIssuesEnabled": true 380 } } }`)) 381 http.Register( 382 httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), 383 httpmock.StringResponse(` 384 { "data": { 385 "u000": { "login": "MonaLisa", "id": "MONAID" }, 386 "repository": { 387 "l000": { "name": "bug", "id": "BUGID" }, 388 "l001": { "name": "TODO", "id": "TODOID" } 389 } 390 } } 391 `)) 392 http.Register( 393 httpmock.GraphQL(`mutation IssueCreate\b`), 394 httpmock.GraphQLMutation(` 395 { "data": { "createIssue": { "issue": { 396 "URL": "https://github.com/OWNER/REPO/issues/12" 397 } } } } 398 `, func(inputs map[string]interface{}) { 399 assert.Equal(t, "recovered title", inputs["title"]) 400 assert.Equal(t, "recovered body", inputs["body"]) 401 assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) 402 })) 403 404 as, teardown := prompt.InitAskStubber() 405 defer teardown() 406 407 as.Stub([]*prompt.QuestionStub{ 408 { 409 Name: "Title", 410 Default: true, 411 }, 412 }) 413 as.Stub([]*prompt.QuestionStub{ 414 { 415 Name: "Body", 416 Default: true, 417 }, 418 }) 419 as.Stub([]*prompt.QuestionStub{ 420 { 421 Name: "confirmation", 422 Value: 0, 423 }, 424 }) 425 426 tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*") 427 assert.NoError(t, err) 428 defer tmpfile.Close() 429 430 state := prShared.IssueMetadataState{ 431 Title: "recovered title", 432 Body: "recovered body", 433 Labels: []string{"bug", "TODO"}, 434 } 435 436 data, err := json.Marshal(state) 437 assert.NoError(t, err) 438 439 _, err = tmpfile.Write(data) 440 assert.NoError(t, err) 441 442 args := fmt.Sprintf("--recover '%s'", tmpfile.Name()) 443 444 output, err := runCommandWithRootDirOverridden(http, true, args, "") 445 if err != nil { 446 t.Errorf("error running command `issue create`: %v", err) 447 } 448 449 assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) 450 } 451 452 func TestIssueCreate_nonLegacyTemplate(t *testing.T) { 453 http := &httpmock.Registry{} 454 defer http.Verify(t) 455 456 http.Register( 457 httpmock.GraphQL(`query RepositoryInfo\b`), 458 httpmock.StringResponse(` 459 { "data": { "repository": { 460 "id": "REPOID", 461 "hasIssuesEnabled": true 462 } } }`), 463 ) 464 http.Register( 465 httpmock.GraphQL(`query IssueTemplates\b`), 466 httpmock.StringResponse(` 467 { "data": { "repository": { "issueTemplates": [ 468 { "name": "Bug report", 469 "body": "Does not work :((" }, 470 { "name": "Submit a request", 471 "body": "I have a suggestion for an enhancement" } 472 ] } } }`), 473 ) 474 http.Register( 475 httpmock.GraphQL(`mutation IssueCreate\b`), 476 httpmock.GraphQLMutation(` 477 { "data": { "createIssue": { "issue": { 478 "URL": "https://github.com/OWNER/REPO/issues/12" 479 } } } }`, 480 func(inputs map[string]interface{}) { 481 assert.Equal(t, inputs["repositoryId"], "REPOID") 482 assert.Equal(t, inputs["title"], "hello") 483 assert.Equal(t, inputs["body"], "I have a suggestion for an enhancement") 484 }), 485 ) 486 487 as, teardown := prompt.InitAskStubber() 488 defer teardown() 489 490 // template 491 as.StubOne(1) 492 // body 493 as.Stub([]*prompt.QuestionStub{ 494 { 495 Name: "Body", 496 Default: true, 497 }, 498 }) // body 499 // confirm 500 as.Stub([]*prompt.QuestionStub{ 501 { 502 Name: "confirmation", 503 Value: 0, 504 }, 505 }) 506 507 output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./fixtures/repoWithNonLegacyIssueTemplates") 508 if err != nil { 509 t.Errorf("error running command `issue create`: %v", err) 510 } 511 512 assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) 513 assert.Equal(t, "", output.BrowsedURL) 514 } 515 516 func TestIssueCreate_continueInBrowser(t *testing.T) { 517 http := &httpmock.Registry{} 518 defer http.Verify(t) 519 520 http.Register( 521 httpmock.GraphQL(`query RepositoryInfo\b`), 522 httpmock.StringResponse(` 523 { "data": { "repository": { 524 "id": "REPOID", 525 "hasIssuesEnabled": true 526 } } }`), 527 ) 528 529 as, teardown := prompt.InitAskStubber() 530 defer teardown() 531 532 // title 533 as.Stub([]*prompt.QuestionStub{ 534 { 535 Name: "Title", 536 Value: "hello", 537 }, 538 }) 539 // confirm 540 as.Stub([]*prompt.QuestionStub{ 541 { 542 Name: "confirmation", 543 Value: 1, 544 }, 545 }) 546 547 _, cmdTeardown := run.Stub() 548 defer cmdTeardown(t) 549 550 output, err := runCommand(http, true, `-b body`) 551 if err != nil { 552 t.Errorf("error running command `issue create`: %v", err) 553 } 554 555 assert.Equal(t, "", output.String()) 556 assert.Equal(t, heredoc.Doc(` 557 558 Creating issue in OWNER/REPO 559 560 Opening github.com/OWNER/REPO/issues/new in your browser. 561 `), output.Stderr()) 562 assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", output.BrowsedURL) 563 } 564 565 func TestIssueCreate_metadata(t *testing.T) { 566 http := &httpmock.Registry{} 567 defer http.Verify(t) 568 569 http.StubRepoInfoResponse("OWNER", "REPO", "main") 570 http.Register( 571 httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), 572 httpmock.StringResponse(` 573 { "data": { 574 "u000": { "login": "MonaLisa", "id": "MONAID" }, 575 "repository": { 576 "l000": { "name": "bug", "id": "BUGID" }, 577 "l001": { "name": "TODO", "id": "TODOID" } 578 } 579 } } 580 `)) 581 http.Register( 582 httpmock.GraphQL(`query RepositoryMilestoneList\b`), 583 httpmock.StringResponse(` 584 { "data": { "repository": { "milestones": { 585 "nodes": [ 586 { "title": "GA", "id": "GAID" }, 587 { "title": "Big One.oh", "id": "BIGONEID" } 588 ], 589 "pageInfo": { "hasNextPage": false } 590 } } } } 591 `)) 592 http.Register( 593 httpmock.GraphQL(`query RepositoryProjectList\b`), 594 httpmock.StringResponse(` 595 { "data": { "repository": { "projects": { 596 "nodes": [ 597 { "name": "Cleanup", "id": "CLEANUPID" }, 598 { "name": "Roadmap", "id": "ROADMAPID" } 599 ], 600 "pageInfo": { "hasNextPage": false } 601 } } } } 602 `)) 603 http.Register( 604 httpmock.GraphQL(`query OrganizationProjectList\b`), 605 httpmock.StringResponse(` 606 { "data": { "organization": null }, 607 "errors": [{ 608 "type": "NOT_FOUND", 609 "path": [ "organization" ], 610 "message": "Could not resolve to an Organization with the login of 'OWNER'." 611 }] 612 } 613 `)) 614 http.Register( 615 httpmock.GraphQL(`mutation IssueCreate\b`), 616 httpmock.GraphQLMutation(` 617 { "data": { "createIssue": { "issue": { 618 "URL": "https://github.com/OWNER/REPO/issues/12" 619 } } } } 620 `, func(inputs map[string]interface{}) { 621 assert.Equal(t, "TITLE", inputs["title"]) 622 assert.Equal(t, "BODY", inputs["body"]) 623 assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"]) 624 assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) 625 assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) 626 assert.Equal(t, "BIGONEID", inputs["milestoneId"]) 627 if v, ok := inputs["userIds"]; ok { 628 t.Errorf("did not expect userIds: %v", v) 629 } 630 if v, ok := inputs["teamIds"]; ok { 631 t.Errorf("did not expect teamIds: %v", v) 632 } 633 })) 634 635 output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) 636 if err != nil { 637 t.Errorf("error running command `issue create`: %v", err) 638 } 639 640 assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) 641 } 642 643 func TestIssueCreate_disabledIssues(t *testing.T) { 644 http := &httpmock.Registry{} 645 defer http.Verify(t) 646 647 http.Register( 648 httpmock.GraphQL(`query RepositoryInfo\b`), 649 httpmock.StringResponse(` 650 { "data": { "repository": { 651 "id": "REPOID", 652 "hasIssuesEnabled": false 653 } } }`), 654 ) 655 656 _, err := runCommand(http, true, `-t heres -b johnny`) 657 if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { 658 t.Errorf("error running command `issue create`: %v", err) 659 } 660 } 661 662 func TestIssueCreate_AtMeAssignee(t *testing.T) { 663 http := &httpmock.Registry{} 664 defer http.Verify(t) 665 666 http.Register( 667 httpmock.GraphQL(`query UserCurrent\b`), 668 httpmock.StringResponse(` 669 { "data": { 670 "viewer": { "login": "MonaLisa" } 671 } } 672 `), 673 ) 674 http.Register( 675 httpmock.GraphQL(`query RepositoryInfo\b`), 676 httpmock.StringResponse(` 677 { "data": { "repository": { 678 "id": "REPOID", 679 "hasIssuesEnabled": true 680 } } } 681 `)) 682 http.Register( 683 httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), 684 httpmock.StringResponse(` 685 { "data": { 686 "u000": { "login": "MonaLisa", "id": "MONAID" }, 687 "u001": { "login": "SomeOneElse", "id": "SOMEID" }, 688 "repository": { 689 "l000": { "name": "bug", "id": "BUGID" }, 690 "l001": { "name": "TODO", "id": "TODOID" } 691 } 692 } } 693 `), 694 ) 695 http.Register( 696 httpmock.GraphQL(`mutation IssueCreate\b`), 697 httpmock.GraphQLMutation(` 698 { "data": { "createIssue": { "issue": { 699 "URL": "https://github.com/OWNER/REPO/issues/12" 700 } } } } 701 `, func(inputs map[string]interface{}) { 702 assert.Equal(t, "hello", inputs["title"]) 703 assert.Equal(t, "cash rules everything around me", inputs["body"]) 704 assert.Equal(t, []interface{}{"MONAID", "SOMEID"}, inputs["assigneeIds"]) 705 })) 706 707 output, err := runCommand(http, true, `-a @me -a someoneelse -t hello -b "cash rules everything around me"`) 708 if err != nil { 709 t.Errorf("error running command `issue create`: %v", err) 710 } 711 712 assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) 713 }