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