package view import ( "bytes" "io" "net/http" "testing" "time" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/jsonfieldstest" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) func TestJSONFields(t *testing.T) { jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdView, []string{ "assignees", "author", "body", "closed", "comments", "closedByPullRequestsReferences", "createdAt", "closedAt", "id", "labels", "milestone", "number", "projectCards", "projectItems", "reactionGroups", "state", "title", "updatedAt", "url", "isPinned", "stateReason", }) } func TestNewCmdView(t *testing.T) { // Test shared parsing of issue number / URL. argparsetest.TestArgParsing(t, NewCmdView) } func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) ios.SetStdinTTY(isTTY) ios.SetStderrTTY(isTTY) factory := &cmdutil.Factory{ IOStreams: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, } cmd := NewCmdView(factory, nil) argv, err := shlex.Split(cli) if err != nil { return nil, err } cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() return &test.CmdOut{ OutBuf: stdout, ErrBuf: stderr, }, err } func TestIssueView_web(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStderrTTY(true) browser := &browser.Stub{} reg := &httpmock.Registry{} defer reg.Verify(t) reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "url": "https://github.com/OWNER/REPO/issues/123" } } } } `)) _, cmdTeardown := run.Stub() defer cmdTeardown(t) err := viewRun(&ViewOptions{ IO: ios, Browser: browser, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, WebMode: true, IssueNumber: 123, }) if err != nil { t.Errorf("error running command `issue view`: %v", err) } assert.Equal(t, "", stdout.String()) assert.Equal(t, "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n", stderr.String()) browser.Verify(t, "https://github.com/OWNER/REPO/issues/123") } func TestIssueView_nontty_Preview(t *testing.T) { tests := map[string]struct { httpStubs func(*httpmock.Registry) expectedOutputs []string }{ "Open issue without metadata": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tOPEN`, `comments:\t9`, `author:\tmarseilles`, `assignees:`, `number:\t123\n`, `\*\*bold story\*\*`, }, }, "Open issue with metadata": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json")) mockV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tix of coins`, `assignees:\tmarseilles, monaco`, `author:\tmarseilles`, `state:\tOPEN`, `comments:\t9`, `labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug`, `projects:\tv2 Project 1 \(No Status\), v2 Project 2 \(Done\), Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, `milestone:\tuluru\n`, `number:\t123\n`, `\*\*bold story\*\*`, }, }, "Open issue with empty body": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tOPEN`, `author:\tmarseilles`, `labels:\ttarot`, `number:\t123\n`, }, }, "Closed issue": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tCLOSED`, `\*\*bold story\*\*`, `author:\tmarseilles`, `labels:\ttarot`, `number:\t123\n`, `\*\*bold story\*\*`, }, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) if tc.httpStubs != nil { tc.httpStubs(http) } output, err := runCommand(http, false, "123") if err != nil { t.Errorf("error running `issue view`: %v", err) } assert.Equal(t, "", output.Stderr()) //nolint:staticcheck // prefer exact matchers over ExpectLines test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) } } func TestIssueView_tty_Preview(t *testing.T) { tests := map[string]struct { httpStubs func(*httpmock.Registry) expectedOutputs []string }{ "Open issue without metadata": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Open issue with metadata": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json")) mockV2ProjectItems(t, r) }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`, `Assignees:.*marseilles, monaco\n`, `Labels:.*Closed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`, `Projects:.*v2 Project 1 \(No Status\), v2 Project 2 \(Done\), Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, `Milestone:.*uluru\n`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Open issue with empty body": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, `No description provided`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Closed issue": { httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Closed.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) httpReg := &httpmock.Registry{} defer httpReg.Verify(t) if tc.httpStubs != nil { tc.httpStubs(httpReg) } opts := ViewOptions{ IO: ios, Now: func() time.Time { t, _ := time.Parse(time.RFC822, "03 Nov 20 15:04 UTC") return t }, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, IssueNumber: 123, } err := viewRun(&opts) assert.NoError(t, err) assert.Equal(t, "", stderr.String()) //nolint:staticcheck // prefer exact matchers over ExpectLines test.ExpectLines(t, stdout.String(), tc.expectedOutputs...) }) } } func TestIssueView_web_notFound(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) http.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "errors": [ { "message": "Could not resolve to an Issue with the number of 9999." } ] } `), ) _, cmdTeardown := run.Stub() defer cmdTeardown(t) _, err := runCommand(http, true, "-w 9999") if err == nil || err.Error() != "GraphQL: Could not resolve to an Issue with the number of 9999." { t.Errorf("error running command `issue view`: %v", err) } } func TestIssueView_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) http.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "id": "REPOID", "hasIssuesEnabled": false } }, "errors": [ { "type": "NOT_FOUND", "path": [ "repository", "issue" ], "message": "Could not resolve to an issue or pull request with the number of 6666." } ] } `), ) _, err := runCommand(http, true, `6666`) if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { t.Errorf("error running command `issue view`: %v", err) } } func TestIssueView_tty_Comments(t *testing.T) { tests := map[string]struct { cli string httpStubs func(*httpmock.Registry) expectedOutputs []string wantsErr bool }{ "without comments flag": { cli: "123", httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `some title OWNER/REPO#123`, `some body`, `———————— Not showing 5 comments ————————`, `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, `Comment 5`, `Use --comments to view the full conversation`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "with comments flag": { cli: "123 --comments", httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `some title OWNER/REPO#123`, `some body`, `monalisa • Jan 1, 2020 • Edited`, `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, `Comment 1`, `johnnytest \(Contributor\) • Jan 1, 2020`, `Comment 2`, `elvisp \(Member\) • Jan 1, 2020`, `Comment 3`, `loislane \(Owner\) • Jan 1, 2020`, `Comment 4`, `sam-spam • This comment has been marked as spam`, `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, `Comment 5`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "with invalid comments flag": { cli: "123 --comments 3", wantsErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) if tc.httpStubs != nil { tc.httpStubs(http) } output, err := runCommand(http, true, tc.cli) if tc.wantsErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, "", output.Stderr()) //nolint:staticcheck // prefer exact matchers over ExpectLines test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) } } func TestIssueView_nontty_Comments(t *testing.T) { tests := map[string]struct { cli string httpStubs func(*httpmock.Registry) expectedOutputs []string wantsErr bool }{ "without comments flag": { cli: "123", httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tsome title`, `state:\tOPEN`, `author:\tmarseilles`, `comments:\t6`, `number:\t123`, `some body`, }, }, "with comments flag": { cli: "123 --comments", httpStubs: func(r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json")) mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `author:\tmonalisa`, `association:\t`, `edited:\ttrue`, `Comment 1`, `author:\tjohnnytest`, `association:\tcontributor`, `edited:\tfalse`, `Comment 2`, `author:\telvisp`, `association:\tmember`, `edited:\tfalse`, `Comment 3`, `author:\tloislane`, `association:\towner`, `edited:\tfalse`, `Comment 4`, `author:\tmarseilles`, `association:\tcollaborator`, `edited:\tfalse`, `Comment 5`, }, }, "with invalid comments flag": { cli: "123 --comments 3", wantsErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) if tc.httpStubs != nil { tc.httpStubs(http) } output, err := runCommand(http, false, tc.cli) if tc.wantsErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, "", output.Stderr()) //nolint:staticcheck // prefer exact matchers over ExpectLines test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) } } // TODO projectsV1Deprecation // Remove this test. func TestProjectsV1Deprecation(t *testing.T) { t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.Register( httpmock.GraphQL(`projectCards`), // Simulate a GraphQL error to early exit the test. httpmock.StatusStringResponse(500, ""), ) _, cmdTeardown := run.Stub() defer cmdTeardown(t) // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. _ = viewRun(&ViewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, Detector: &fd.EnabledDetectorMock{}, IssueNumber: 123, }) // Verify that our request contained projectCards reg.Verify(t) }) t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { ios, _, _, _ := iostreams.Test() reg := &httpmock.Registry{} reg.Exclude(t, httpmock.GraphQL(`projectCards`)) _, cmdTeardown := run.Stub() defer cmdTeardown(t) // Ignore the error because we're not really interested in it. _ = viewRun(&ViewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, Detector: &fd.DisabledDetectorMock{}, IssueNumber: 123, }) // Verify that our request contained projectCards reg.Verify(t) }) } // mockEmptyV2ProjectItems registers GraphQL queries to report an issue is not contained on any v2 projects. func mockEmptyV2ProjectItems(t *testing.T, r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(` { "data": { "repository": { "issue": { "projectItems": { "totalCount": 0, "nodes": [] } } } } } `)) } // mockV2ProjectItems registers GraphQL queries to report an issue on multiple v2 projects in various states // - `NO_STATUS_ITEM`: emulates this issue is on a project but is not given a status // - `DONE_STATUS_ITEM`: emulates this issue is on a project and considered done func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) { r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(` { "data": { "repository": { "issue": { "projectItems": { "totalCount": 2, "nodes": [ { "id": "NO_STATUS_ITEM", "project": { "id": "PROJECT1", "title": "v2 Project 1" }, "status": { "optionId": "", "name": "" } }, { "id": "DONE_STATUS_ITEM", "project": { "id": "PROJECT2", "title": "v2 Project 2" }, "status": { "optionId": "PROJECTITEMFIELD1", "name": "Done" } } ] } } } } } `)) }