From 83bcdc5df675bcf9ceeedb8fd949efdf036cde80 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 21 Aug 2024 08:09:57 -0400 Subject: [PATCH 01/21] 2nd attempt to feature detect v1 project support This commit is an alternative approach from the original draft, creating a separate feature detection method rather than modifying the repository features. Additionally, this approach attempts to avoid need of mocking GraphQL calls by increasing the number of commands that can have a detector injected. Finally, this commit includes the `INCLUDE_PROJECT_V1` environment variable to externally overwrite the feature detection logic, avoiding the need for specialized GitHub monolith instances to test. --- api/queries_repo.go | 49 ++++++++++--------- api/queries_repo_test.go | 6 +-- internal/featuredetection/detector_mock.go | 8 +++ .../featuredetection/feature_detection.go | 32 ++++++++++++ pkg/cmd/issue/close/close.go | 3 +- pkg/cmd/issue/comment/comment.go | 2 +- pkg/cmd/issue/create/create.go | 25 +++++++--- pkg/cmd/issue/create/create_test.go | 3 ++ pkg/cmd/issue/delete/delete.go | 4 +- pkg/cmd/issue/develop/develop.go | 4 +- pkg/cmd/issue/edit/edit.go | 24 +++++++-- pkg/cmd/issue/edit/edit_test.go | 2 + pkg/cmd/issue/lock/lock.go | 4 +- pkg/cmd/issue/pin/pin.go | 2 +- pkg/cmd/issue/reopen/reopen.go | 2 +- pkg/cmd/issue/shared/lookup.go | 26 +++++++--- pkg/cmd/issue/shared/lookup_test.go | 5 +- pkg/cmd/issue/transfer/transfer.go | 4 +- pkg/cmd/issue/unpin/unpin.go | 4 +- pkg/cmd/issue/view/view.go | 8 +-- pkg/cmd/issue/view/view_test.go | 7 ++- pkg/cmd/pr/create/create.go | 19 +++++-- pkg/cmd/pr/create/create_test.go | 2 + pkg/cmd/pr/edit/edit.go | 20 ++++++-- pkg/cmd/pr/edit/edit_test.go | 6 ++- pkg/cmd/pr/shared/commentable.go | 2 + pkg/cmd/pr/shared/completion.go | 2 +- pkg/cmd/pr/shared/editable.go | 4 +- pkg/cmd/pr/shared/finder.go | 20 ++++++-- pkg/cmd/pr/shared/params.go | 12 ++--- pkg/cmd/pr/shared/params_test.go | 2 +- pkg/cmd/pr/shared/survey.go | 10 ++-- pkg/cmd/pr/shared/survey_test.go | 6 +-- 33 files changed, 242 insertions(+), 87 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 53e6d879a47..70084b8c5b1 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -868,7 +868,7 @@ type RepoMetadataInput struct { } // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests -func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) { +func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput, includeProjectV1 bool) (*RepoMetadataResult, error) { var result RepoMetadataResult var g errgroup.Group @@ -917,7 +917,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput if input.Projects { g.Go(func() error { var err error - result.Projects, result.ProjectsV2, err = relevantProjects(client, repo) + result.Projects, result.ProjectsV2, err = relevantProjects(client, repo, includeProjectV1) return err }) } @@ -948,7 +948,7 @@ type RepoResolveInput struct { } // RepoResolveMetadataIDs looks up GraphQL node IDs in bulk -func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput) (*RepoMetadataResult, error) { +func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput, includeProjectV1 bool) (*RepoMetadataResult, error) { users := input.Assignees hasUser := func(target string) bool { for _, u := range users { @@ -973,7 +973,7 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes Projects: len(input.Projects) > 0, Milestones: len(input.Milestones) > 0, } - result, err := RepoMetadata(client, repo, mi) + result, err := RepoMetadata(client, repo, mi, includeProjectV1) if err != nil { return result, err } @@ -1237,8 +1237,8 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo return milestones, nil } -func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) { - projects, projectsV2, err := relevantProjects(client, repo) +func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, includeProjectV1 bool) ([]string, error) { + projects, projectsV2, err := relevantProjects(client, repo, includeProjectV1) if err != nil { return nil, err } @@ -1251,7 +1251,7 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s // - ProjectsV2 owned by current user // - ProjectsV2 linked to repository // - ProjectsV2 owned by repository organization, if it belongs to one -func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) { +func relevantProjects(client *Client, repo ghrepo.Interface, includeProjectV1 bool) ([]RepoProject, []ProjectV2, error) { var repoProjects []RepoProject var orgProjects []RepoProject var userProjectsV2 []ProjectV2 @@ -1260,23 +1260,26 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P g, _ := errgroup.WithContext(context.Background()) - g.Go(func() error { - var err error - repoProjects, err = RepoProjects(client, repo) - if err != nil { - err = fmt.Errorf("error fetching repo projects (classic): %w", err) - } - return err - }) - g.Go(func() error { - var err error - orgProjects, err = OrganizationProjects(client, repo) - if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { - err = fmt.Errorf("error fetching organization projects (classic): %w", err) + if includeProjectV1 { + g.Go(func() error { + var err error + repoProjects, err = RepoProjects(client, repo) + if err != nil { + err = fmt.Errorf("error fetching repo projects (classic): %w", err) + } return err - } - return nil - }) + }) + g.Go(func() error { + var err error + orgProjects, err = OrganizationProjects(client, repo) + if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + err = fmt.Errorf("error fetching organization projects (classic): %w", err) + return err + } + return nil + }) + } + g.Go(func() error { var err error userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost()) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 13aee459a1e..9a8de8c3f6b 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -151,7 +151,7 @@ func Test_RepoMetadata(t *testing.T) { { "data": { "viewer": { "login": "monalisa" } } } `)) - result, err := RepoMetadata(client, repo, input) + result, err := RepoMetadata(client, repo, input, true) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -292,7 +292,7 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}) + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, true) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -352,7 +352,7 @@ t001: team(slug:"robots"){id,slug} } })) - result, err := RepoResolveMetadataIDs(client, repo, input) + result, err := RepoResolveMetadataIDs(client, repo, input, false) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 6f36dd3fc03..7032e4c677e 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -14,6 +14,10 @@ func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) return RepositoryFeatures{}, nil } +func (md *DisabledDetectorMock) ProjectV1() (bool, error) { + return false, nil +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -27,3 +31,7 @@ func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) { return allRepositoryFeatures, nil } + +func (md *EnabledDetectorMock) ProjectV1() (bool, error) { + return true, nil +} diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index a9bbe25f851..3d990a1c4ee 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -2,6 +2,8 @@ package featuredetection import ( "net/http" + "os" + "strconv" "github.com/cli/cli/v2/api" "golang.org/x/sync/errgroup" @@ -13,6 +15,7 @@ type Detector interface { IssueFeatures() (IssueFeatures, error) PullRequestFeatures() (PullRequestFeatures, error) RepositoryFeatures() (RepositoryFeatures, error) + ProjectV1() (bool, error) } type IssueFeatures struct { @@ -199,3 +202,32 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) { return features, nil } + +func (d *detector) ProjectV1() (bool, error) { + // Bypass feature detection logic for testing purposes + if env := os.Getenv("INCLUDE_PROJECT_V1"); env != "" { + return strconv.ParseBool(env) + } + var featureDetection struct { + Repository struct { + Fields []struct { + Name string + } `graphql:"fields(includeDeprecated: true)"` + } `graphql:"Repository: __type(name: \"Repository\")"` + } + + gql := api.NewClientFromHTTP(d.httpClient) + + err := gql.Query(d.host, "ProjectV1FeatureDetection", &featureDetection, nil) + if err != nil { + return false, err + } + + for _, field := range featureDetection.Repository.Fields { + if field.Name == "projects" { + return true, nil + } + } + + return false, nil +} diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index 9197abff661..5e4845d48c8 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -67,7 +67,7 @@ func closeRun(opts *CloseOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}, opts.Detector) if err != nil { return err } @@ -109,6 +109,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, } if reason != "" { + // Should this be moved up into closeRun()? if detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) detector = fd.NewDetector(cachedClient, repo.RepoHost()) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 090b0748c4b..414dfaccf9a 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -45,7 +45,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e if opts.EditLast { fields = append(fields, "comments") } - return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields) + return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields, opts.Detector) } return prShared.CommentablePreRun(cmd, opts) }, diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 2e3e0de519a..89d6df6b66a 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -4,10 +4,12 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + 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/text" @@ -25,6 +27,7 @@ type CreateOptions struct { Browser browser.Browser Prompter prShared.Prompt TitledEditSurvey func(string, string) (string, string, error) + Detector fd.Detector RootDirOverride string @@ -146,6 +149,16 @@ func createRun(opts *CreateOptions) (err error) { return } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + includeProjectV1, err := opts.Detector.ProjectV1() + if err != nil { + return err + } + isTerminal := opts.IO.IsStdoutTTY() var milestones []string @@ -182,7 +195,7 @@ func createRun(opts *CreateOptions) (err error) { if opts.WebMode { var openURL string if opts.Title != "" || opts.Body != "" || tb.HasMetadata() { - openURL, err = generatePreviewURL(apiClient, baseRepo, tb) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, includeProjectV1) if err != nil { return } @@ -260,7 +273,7 @@ func createRun(opts *CreateOptions) (err error) { } } - openURL, err = generatePreviewURL(apiClient, baseRepo, tb) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, includeProjectV1) if err != nil { return } @@ -279,7 +292,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, includeProjectV1) if err != nil { return } @@ -335,7 +348,7 @@ func createRun(opts *CreateOptions) (err error) { params["issueTemplate"] = templateNameForSubmit } - err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) + err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, includeProjectV1) if err != nil { return } @@ -354,7 +367,7 @@ func createRun(opts *CreateOptions) (err error) { return } -func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState) (string, error) { +func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, includeProjectV1 bool) (string, error) { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb) + return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, includeProjectV1) } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 8e49700a012..2295c4c50f7 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + "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/prompter" @@ -475,6 +476,7 @@ func Test_createRun(t *testing.T) { } browser := &browser.Stub{} opts.Browser = browser + opts.Detector = &featuredetection.EnabledDetectorMock{} err := createRun(opts) if tt.wantsErr == "" { @@ -521,6 +523,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { opts.RootDirOverride = rootDir + opts.Detector = &featuredetection.EnabledDetectorMock{} return createRun(opts) }) diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go index fb41f288e56..6976b7e04a5 100644 --- a/pkg/cmd/issue/delete/delete.go +++ b/pkg/cmd/issue/delete/delete.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + 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/pkg/cmd/issue/shared" @@ -20,6 +21,7 @@ type DeleteOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter iprompter + Detector fd.Detector SelectorArg string Confirmed bool @@ -71,7 +73,7 @@ func deleteRun(opts *DeleteOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title"}, opts.Detector) if err != nil { return err } diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 1536800f072..a37d35a421a 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -23,6 +24,7 @@ type DevelopOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Remotes func() (context.Remotes, error) + Detector fd.Detector IssueSelector string Name string @@ -132,7 +134,7 @@ func developRun(opts *DevelopOptions) error { } opts.IO.StartProgressIndicator() - issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}, opts.Detector) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 18067319f40..c6d8a76330f 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -5,9 +5,11 @@ import ( "net/http" "sort" "sync" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -22,11 +24,12 @@ type EditOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter prShared.EditPrompter + Detector fd.Detector DetermineEditor func() (string, error) FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error - FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error + FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable, bool) error SelectorArgs []string Interactive bool @@ -167,6 +170,21 @@ func editRun(opts *EditOptions) error { return err } + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + includeProjectV1, err := opts.Detector.ProjectV1() + if err != nil { + return err + } + // Prompt the user which fields they'd like to edit. editable := opts.Editable if opts.Interactive { @@ -192,7 +210,7 @@ func editRun(opts *EditOptions) error { } // Get all specified issues and make sure they are within the same repo. - issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields) + issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields, opts.Detector) if err != nil { return err } @@ -200,7 +218,7 @@ func editRun(opts *EditOptions) error { // Fetch editable shared fields once for all issues. apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicatorWithLabel("Fetching repository information") - err = opts.FetchOptions(apiClient, repo, &editable) + err = opts.FetchOptions(apiClient, repo, &editable, includeProjectV1) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 40fe6491cbe..2f74944dbe2 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -577,6 +578,7 @@ func Test_editRun(t *testing.T) { tt.input.IO = ios tt.input.HttpClient = httpClient tt.input.BaseRepo = baseRepo + tt.input.Detector = &featuredetection.EnabledDetectorMock{} err := editRun(tt.input) if tt.wantErr { diff --git a/pkg/cmd/issue/lock/lock.go b/pkg/cmd/issue/lock/lock.go index 4e0dac05815..d7df78afca4 100644 --- a/pkg/cmd/issue/lock/lock.go +++ b/pkg/cmd/issue/lock/lock.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -96,6 +97,7 @@ type LockOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter iprompter + Detector fd.Detector ParentCmd string Reason string @@ -214,7 +216,7 @@ func lockRun(state string, opts *LockOptions) error { return err } - issuePr, baseRepo, err := issueShared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, fields()) + issuePr, baseRepo, err := issueShared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, fields(), opts.Detector) parent := alias[opts.ParentCmd] diff --git a/pkg/cmd/issue/pin/pin.go b/pkg/cmd/issue/pin/pin.go index dfb11a8811c..6488e43028e 100644 --- a/pkg/cmd/issue/pin/pin.go +++ b/pkg/cmd/issue/pin/pin.go @@ -73,7 +73,7 @@ func pinRun(opts *PinOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}, nil) if err != nil { return err } diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go index 92f18a7d979..c39e6076455 100644 --- a/pkg/cmd/issue/reopen/reopen.go +++ b/pkg/cmd/issue/reopen/reopen.go @@ -64,7 +64,7 @@ func reopenRun(opts *ReopenOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}, nil) if err != nil { return err } diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index be79f9a73e6..fbe9787e6af 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -19,7 +19,7 @@ import ( // IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields // could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError. -func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) { +func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg) if err != nil { return nil, nil, err @@ -32,13 +32,13 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I } } - issue, err := findIssueOrPR(httpClient, baseRepo, issueNumber, fields) + issue, err := findIssueOrPR(httpClient, baseRepo, issueNumber, fields, detector) return issue, baseRepo, err } // IssuesFromArgsWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields // could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError. -func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) { +func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string, detector fd.Detector) ([]*api.Issue, ghrepo.Interface, error) { var issuesRepo ghrepo.Interface issueNumbers := make([]int, 0, len(args)) @@ -75,7 +75,7 @@ func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo for _, num := range issueNumbers { issueNumber := num g.Go(func() error { - issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields) + issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields, detector) if err != nil { return err } @@ -142,12 +142,15 @@ type PartialLoadError struct { error } -func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { +func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string, detector fd.Detector) (*api.Issue, error) { + if detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + fieldSet := set.NewStringSet() fieldSet.AddValues(fields) if fieldSet.Contains("stateReason") { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - detector := fd.NewDetector(cachedClient, repo.RepoHost()) features, err := detector.IssueFeatures() if err != nil { return nil, err @@ -163,6 +166,15 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f fieldSet.Remove("projectItems") fieldSet.Add("number") } + if fieldSet.Contains("projectCards") { + includeProjectV1, err := detector.ProjectV1() + if err != nil { + return nil, err + } + if !includeProjectV1 { + fieldSet.Remove("projectCards") + } + } fields = fieldSet.ToSlice() diff --git a/pkg/cmd/issue/shared/lookup_test.go b/pkg/cmd/issue/shared/lookup_test.go index 44f496de44a..1261d3bf44b 100644 --- a/pkg/cmd/issue/shared/lookup_test.go +++ b/pkg/cmd/issue/shared/lookup_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -193,7 +194,7 @@ func TestIssueFromArgWithFields(t *testing.T) { tt.httpStub(reg) } httpClient := &http.Client{Transport: reg} - issue, repo, err := IssueFromArgWithFields(httpClient, tt.args.baseRepoFn, tt.args.selector, []string{"number"}) + issue, repo, err := IssueFromArgWithFields(httpClient, tt.args.baseRepoFn, tt.args.selector, []string{"number"}, &fd.EnabledDetectorMock{}) if (err != nil) != tt.wantErr { t.Errorf("IssueFromArgWithFields() error = %v, wantErr %v", err, tt.wantErr) if issue == nil { @@ -289,7 +290,7 @@ func TestIssuesFromArgsWithFields(t *testing.T) { tt.httpStub(reg) } httpClient := &http.Client{Transport: reg} - issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"}) + issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"}, &fd.EnabledDetectorMock{}) if (err != nil) != tt.wantErr { t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr) if issues == nil { diff --git a/pkg/cmd/issue/transfer/transfer.go b/pkg/cmd/issue/transfer/transfer.go index 140d02b9194..f2c70181445 100644 --- a/pkg/cmd/issue/transfer/transfer.go +++ b/pkg/cmd/issue/transfer/transfer.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + 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/pkg/cmd/issue/shared" @@ -19,6 +20,7 @@ type TransferOptions struct { Config func() (gh.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Detector fd.Detector IssueSelector string DestRepoSelector string @@ -57,7 +59,7 @@ func transferRun(opts *TransferOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}, opts.Detector) if err != nil { return err } diff --git a/pkg/cmd/issue/unpin/unpin.go b/pkg/cmd/issue/unpin/unpin.go index 3ac28d47cf5..8e820ed54bb 100644 --- a/pkg/cmd/issue/unpin/unpin.go +++ b/pkg/cmd/issue/unpin/unpin.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + 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/pkg/cmd/issue/shared" @@ -21,6 +22,7 @@ type UnpinOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) SelectorArg string + Detector fd.Detector } func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Command { @@ -73,7 +75,7 @@ func unpinRun(opts *UnpinOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}) + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}, opts.Detector) if err != nil { return err } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b188c6a4ca3..c0a8c294c98 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -12,6 +12,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -28,6 +29,7 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Detector fd.Detector SelectorArg string WebMode bool @@ -103,7 +105,7 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() - issue, baseRepo, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice()) + issue, baseRepo, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice(), opts.Detector) opts.IO.StopProgressIndicator() if err != nil { var loadErr *issueShared.PartialLoadError @@ -143,12 +145,12 @@ func viewRun(opts *ViewOptions) error { return printRawIssuePreview(opts.IO.Out, issue) } -func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string) (*api.Issue, ghrepo.Interface, error) { +func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { fieldSet := set.NewStringSet() fieldSet.AddValues(fields) fieldSet.Add("id") - issue, repo, err := issueShared.IssueFromArgWithFields(client, baseRepoFn, selector, fieldSet.ToSlice()) + issue, repo, err := issueShared.IssueFromArgWithFields(client, baseRepoFn, selector, fieldSet.ToSlice(), detector) if err != nil { return issue, repo, err } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index e1798af9f83..6847d9178c6 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + "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" @@ -66,7 +67,10 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err }, } - cmd := NewCmdView(factory, nil) + cmd := NewCmdView(factory, func(opts *ViewOptions) error { + opts.Detector = &featuredetection.EnabledDetectorMock{} + return viewRun(opts) + }) argv, err := shlex.Split(cli) if err != nil { @@ -274,6 +278,7 @@ func TestIssueView_tty_Preview(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil }, SelectorArg: "123", + Detector: &featuredetection.EnabledDetectorMock{}, } err := viewRun(&opts) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0066bbc6e02..0e9d6cd97c3 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -18,6 +18,7 @@ import ( ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" + 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/text" @@ -40,6 +41,7 @@ type CreateOptions struct { Prompter shared.Prompt Finder shared.PRFinder TitledEditSurvey func(string, string) (string, string, error) + Detector fd.Detector TitleProvided bool BodyProvided bool @@ -86,6 +88,7 @@ type CreateContext struct { IsPushEnabled bool Client *api.Client GitClient *git.Client + IncludeProjectV1 bool } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -442,7 +445,7 @@ func createRun(opts *CreateOptions) error { Repo: ctx.BaseRepo, State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state, ctx.IncludeProjectV1) if err != nil { return err } @@ -777,6 +780,15 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + includeProjectV1, err := opts.Detector.ProjectV1() + if err != nil { + return nil, err + } + return &CreateContext{ BaseRepo: baseRepo, HeadRepo: headRepo, @@ -789,6 +801,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { RepoContext: repoContext, Client: client, GitClient: gitClient, + IncludeProjectV1: includeProjectV1, }, nil } @@ -821,7 +834,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return errors.New("pull request title must not be blank") } - err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state) + err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state, ctx.IncludeProjectV1) if err != nil { return err } @@ -1058,7 +1071,7 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str ctx.BaseRepo, "compare/%s...%s?expand=1", url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel)) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state, ctx.IncludeProjectV1) if err != nil { return "", err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 55012d7ddd9..4404298ae20 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + "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/prompter" @@ -1593,6 +1594,7 @@ func Test_createRun(t *testing.T) { GhPath: "some/path/gh", GitPath: "some/path/git", } + opts.Detector = &featuredetection.EnabledDetectorMock{} cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 3c8d73ad393..c294c3bf6a5 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -3,9 +3,11 @@ package edit import ( "fmt" "net/http" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -25,6 +27,7 @@ type EditOptions struct { Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever Prompter shared.EditPrompter + Detector fd.Detector SelectorArg string Interactive bool @@ -230,8 +233,17 @@ func editRun(opts *EditOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + includeProjectV1, err := opts.Detector.ProjectV1() + if err != nil { + return err + } + opts.IO.StartProgressIndicator() - err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable) + err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, includeProjectV1) opts.IO.StopProgressIndicator() if err != nil { return err @@ -311,13 +323,13 @@ func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error } type EditableOptionsFetcher interface { - EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable) error + EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable, bool) error } type fetcher struct{} -func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error { - return shared.FetchOptions(client, repo, opts) +func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, includeProjectV1 bool) error { + return shared.FetchOptions(client, repo, opts, includeProjectV1) } type EditorRetriever interface { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 3c4882961ad..f17626533ee 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -502,6 +503,7 @@ func Test_editRun(t *testing.T) { tt.input.IO = ios tt.input.HttpClient = httpClient + tt.input.Detector = &featuredetection.EnabledDetectorMock{} err := editRun(tt.input) assert.NoError(t, err) @@ -661,8 +663,8 @@ type testSurveyor struct { } type testEditorRetriever struct{} -func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error { - return shared.FetchOptions(client, repo, opts) +func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, includeProjectV1 bool) error { + return shared.FetchOptions(client, repo, opts, includeProjectV1) } func (s testSurveyor) FieldsToEdit(e *shared.Editable) error { diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index f909c755934..0bb38652065 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + 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/text" @@ -36,6 +37,7 @@ type Commentable interface { type CommentableOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) + Detector fd.Detector RetrieveCommentable func() (Commentable, ghrepo.Interface, error) EditSurvey func(string) (string, error) InteractiveEditSurvey func(string) (string, error) diff --git a/pkg/cmd/pr/shared/completion.go b/pkg/cmd/pr/shared/completion.go index e07abc5a73f..20a5e3ac17a 100644 --- a/pkg/cmd/pr/shared/completion.go +++ b/pkg/cmd/pr/shared/completion.go @@ -14,7 +14,7 @@ import ( func RequestableReviewersForCompletion(httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { client := api.NewClientFromHTTP(api.NewCachedHTTPClient(httpClient, time.Minute*2)) - metadata, err := api.RepoMetadata(client, repo, api.RepoMetadataInput{Reviewers: true}) + metadata, err := api.RepoMetadata(client, repo, api.RepoMetadataInput{Reviewers: true}, false) if err != nil { return nil, err } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index cec3bfe8c9c..96bff7a18c2 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -376,7 +376,7 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { return nil } -func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) error { +func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, includeProjectV1 bool) error { input := api.RepoMetadataInput{ Reviewers: editable.Reviewers.Edited, Assignees: editable.Assignees.Edited, @@ -384,7 +384,7 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) Projects: editable.Projects.Edited, Milestones: editable.Milestone.Edited, } - metadata, err := api.RepoMetadata(client, repo, input) + metadata, err := api.RepoMetadata(client, repo, input, includeProjectV1) if err != nil { return err } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index a5452852788..e32c9953076 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -95,6 +95,8 @@ type FindOptions struct { BaseBranch string // States lists the possible PR states to scope the PR-for-branch lookup to. States []string + // Detector determines which features are available. + Detector fd.Detector } // TODO: Does this also need the BaseBranchName? @@ -214,6 +216,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return nil, nil, err } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + } + // TODO(josebalius): Should we be guarding here? if f.progress != nil { f.progress.StartProgressIndicator() @@ -226,9 +233,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields.AddValues([]string{"id", "number"}) // for additional preload queries below if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - detector := fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) - prFeatures, err := detector.PullRequestFeatures() + prFeatures, err := opts.Detector.PullRequestFeatures() if err != nil { return nil, nil, err } @@ -243,6 +248,15 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err getProjectItems = true fields.Remove("projectItems") } + if fields.Contains("projectCards") { + includeProjectV1, err := opts.Detector.ProjectV1() + if err != nil { + return nil, nil, err + } + if !includeProjectV1 { + fields.Remove("projectCards") + } + } var pr *api.PullRequest if f.prNumber > 0 { diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 128c51068a0..022add0430f 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -11,7 +11,7 @@ import ( "github.com/google/shlex" ) -func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState) (string, error) { +func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, includeProjectV1 bool) (string, error) { u, err := url.Parse(baseURL) if err != nil { return "", err @@ -35,7 +35,7 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba q.Set("labels", strings.Join(state.Labels, ",")) } if len(state.Projects) > 0 { - projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects) + projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects, includeProjectV1) if err != nil { return "", fmt.Errorf("could not add to project: %w", err) } @@ -56,7 +56,7 @@ func ValidURL(urlStr string) bool { // Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able // to resolve all object listed in tb to GraphQL IDs. -func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error { +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, includeProjectV1 bool) error { resolveInput := api.RepoResolveInput{} if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { @@ -79,7 +79,7 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada resolveInput.Milestones = tb.Milestones } - metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) + metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput, includeProjectV1) if err != nil { return err } @@ -93,12 +93,12 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada return nil } -func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error { +func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, includeProjectV1 bool) error { if !tb.HasMetadata() { return nil } - if err := fillMetadata(client, baseRepo, tb); err != nil { + if err := fillMetadata(client, baseRepo, tb, includeProjectV1); err != nil { return err } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 5f5e674cc0f..b6c8fd72c39 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -265,7 +265,7 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state) + got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state, false) if (err != nil) != tt.wantErr { t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index ce38535d97b..d3207003ab3 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -139,19 +139,19 @@ type MetadataFetcher struct { State *IssueMetadataState } -func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { +func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, includeProjectV1 bool) (*api.RepoMetadataResult, error) { mf.IO.StartProgressIndicator() - metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input) + metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input, includeProjectV1) mf.IO.StopProgressIndicator() mf.State.MetadataResult = metadataResult return metadataResult, err } type RepoMetadataFetcher interface { - RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) + RepoMetadataFetch(api.RepoMetadataInput, bool) (*api.RepoMetadataResult, error) } -func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, includeProjectV1 bool) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -184,7 +184,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) + metadataResult, err := fetcher.RepoMetadataFetch(metadataInput, includeProjectV1) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index d74696460a2..3835a2c001b 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -14,7 +14,7 @@ type metadataFetcher struct { metadataResult *api.RepoMetadataResult } -func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { +func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, includeProjectV1 bool) (*api.RepoMetadataResult, error) { return mf.metadataResult, nil } @@ -68,7 +68,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { Assignees: []string{"hubot"}, Type: PRMetadata, } - err := MetadataSurvey(pm, ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state, true) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -113,7 +113,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, } - err := MetadataSurvey(pm, ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state, true) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) From 61f261289c07cbff394b2b93acd46531e876c8b7 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 22 Aug 2024 12:22:57 +0200 Subject: [PATCH 02/21] Add type safety and readability to ProjectV1 support code --- api/queries_repo.go | 17 ++-- api/queries_repo_test.go | 7 +- internal/featuredetection/detector_mock.go | 10 ++- .../featuredetection/feature_detection.go | 24 +++--- .../feature_detection_test.go | 82 +++++++++++++++++++ internal/gh/projects.go | 30 +++++++ pkg/cmd/issue/create/create.go | 14 ++-- pkg/cmd/issue/edit/edit.go | 7 +- pkg/cmd/issue/shared/lookup.go | 5 +- pkg/cmd/pr/create/create.go | 12 +-- pkg/cmd/pr/edit/edit.go | 10 +-- pkg/cmd/pr/edit/edit_test.go | 5 +- pkg/cmd/pr/shared/completion.go | 3 +- pkg/cmd/pr/shared/editable.go | 5 +- pkg/cmd/pr/shared/finder.go | 5 +- pkg/cmd/pr/shared/params.go | 13 +-- pkg/cmd/pr/shared/params_test.go | 3 +- pkg/cmd/pr/shared/survey.go | 10 +-- pkg/cmd/pr/shared/survey_test.go | 7 +- 19 files changed, 199 insertions(+), 70 deletions(-) create mode 100644 internal/gh/projects.go diff --git a/api/queries_repo.go b/api/queries_repo.go index 70084b8c5b1..0ea6378215d 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "golang.org/x/sync/errgroup" @@ -868,7 +869,7 @@ type RepoMetadataInput struct { } // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests -func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput, includeProjectV1 bool) (*RepoMetadataResult, error) { +func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput, projectsV1Support gh.ProjectsV1Support) (*RepoMetadataResult, error) { var result RepoMetadataResult var g errgroup.Group @@ -917,7 +918,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput if input.Projects { g.Go(func() error { var err error - result.Projects, result.ProjectsV2, err = relevantProjects(client, repo, includeProjectV1) + result.Projects, result.ProjectsV2, err = relevantProjects(client, repo, projectsV1Support) return err }) } @@ -948,7 +949,7 @@ type RepoResolveInput struct { } // RepoResolveMetadataIDs looks up GraphQL node IDs in bulk -func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput, includeProjectV1 bool) (*RepoMetadataResult, error) { +func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput, projectsV1Support gh.ProjectsV1Support) (*RepoMetadataResult, error) { users := input.Assignees hasUser := func(target string) bool { for _, u := range users { @@ -973,7 +974,7 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes Projects: len(input.Projects) > 0, Milestones: len(input.Milestones) > 0, } - result, err := RepoMetadata(client, repo, mi, includeProjectV1) + result, err := RepoMetadata(client, repo, mi, projectsV1Support) if err != nil { return result, err } @@ -1237,8 +1238,8 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo return milestones, nil } -func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, includeProjectV1 bool) ([]string, error) { - projects, projectsV2, err := relevantProjects(client, repo, includeProjectV1) +func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { + projects, projectsV2, err := relevantProjects(client, repo, projectsV1Support) if err != nil { return nil, err } @@ -1251,7 +1252,7 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s // - ProjectsV2 owned by current user // - ProjectsV2 linked to repository // - ProjectsV2 owned by repository organization, if it belongs to one -func relevantProjects(client *Client, repo ghrepo.Interface, includeProjectV1 bool) ([]RepoProject, []ProjectV2, error) { +func relevantProjects(client *Client, repo ghrepo.Interface, projectsV1Support gh.ProjectsV1Support) ([]RepoProject, []ProjectV2, error) { var repoProjects []RepoProject var orgProjects []RepoProject var userProjectsV2 []ProjectV2 @@ -1260,7 +1261,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface, includeProjectV1 bo g, _ := errgroup.WithContext(context.Background()) - if includeProjectV1 { + if projectsV1Support == gh.ProjectsV1Supported { g.Go(func() error { var err error repoProjects, err = RepoProjects(client, repo) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 9a8de8c3f6b..922090c6c09 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -151,7 +152,7 @@ func Test_RepoMetadata(t *testing.T) { { "data": { "viewer": { "login": "monalisa" } } } `)) - result, err := RepoMetadata(client, repo, input, true) + result, err := RepoMetadata(client, repo, input, gh.ProjectsV1Supported) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -292,7 +293,7 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, true) + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -352,7 +353,7 @@ t001: team(slug:"robots"){id,slug} } })) - result, err := RepoResolveMetadataIDs(client, repo, input, false) + result, err := RepoResolveMetadataIDs(client, repo, input, gh.ProjectsV1Supported) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 7032e4c677e..4ba51a3c8d8 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -1,5 +1,7 @@ package featuredetection +import "github.com/cli/cli/v2/internal/gh" + type DisabledDetectorMock struct{} func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -14,8 +16,8 @@ func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) return RepositoryFeatures{}, nil } -func (md *DisabledDetectorMock) ProjectV1() (bool, error) { - return false, nil +func (md *DisabledDetectorMock) ProjectsV1() (gh.ProjectsV1Support, error) { + return gh.ProjectsV1Unsupported, nil } type EnabledDetectorMock struct{} @@ -32,6 +34,6 @@ func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) return allRepositoryFeatures, nil } -func (md *EnabledDetectorMock) ProjectV1() (bool, error) { - return true, nil +func (md *EnabledDetectorMock) ProjectsV1() (gh.ProjectsV1Support, error) { + return gh.ProjectsV1Supported, nil } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 3d990a1c4ee..cc17032158e 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "golang.org/x/sync/errgroup" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -15,7 +16,7 @@ type Detector interface { IssueFeatures() (IssueFeatures, error) PullRequestFeatures() (PullRequestFeatures, error) RepositoryFeatures() (RepositoryFeatures, error) - ProjectV1() (bool, error) + ProjectsV1() (gh.ProjectsV1Support, error) } type IssueFeatures struct { @@ -203,11 +204,16 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) { return features, nil } -func (d *detector) ProjectV1() (bool, error) { +func (d *detector) ProjectsV1() (gh.ProjectsV1Support, error) { // Bypass feature detection logic for testing purposes - if env := os.Getenv("INCLUDE_PROJECT_V1"); env != "" { - return strconv.ParseBool(env) + if env := os.Getenv("PROJECTS_V1_SUPPORTED"); env != "" { + b, err := strconv.ParseBool(env) + if err != nil { + return nil, err + } + return gh.ParseProjectsV1Support(b), nil } + var featureDetection struct { Repository struct { Fields []struct { @@ -217,17 +223,15 @@ func (d *detector) ProjectV1() (bool, error) { } gql := api.NewClientFromHTTP(d.httpClient) - - err := gql.Query(d.host, "ProjectV1FeatureDetection", &featureDetection, nil) - if err != nil { - return false, err + if err := gql.Query(d.host, "ProjectsV1FeatureDetection", &featureDetection, nil); err != nil { + return nil, err } for _, field := range featureDetection.Repository.Fields { if field.Name == "projects" { - return true, nil + return gh.ProjectsV1Supported, nil } } - return false, nil + return gh.ProjectsV1Unsupported, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 8af091c3f01..ee31b511020 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -5,8 +5,10 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIssueFeatures(t *testing.T) { @@ -366,3 +368,83 @@ func TestRepositoryFeatures(t *testing.T) { }) } } + +func TestProjectV1SupportEnvOverride(t *testing.T) { + tt := []struct { + name string + envVal string + expectedSupport gh.ProjectsV1Support + expectedErr bool + }{ + { + name: "PROJECTS_V1_SUPPORTED=true", + envVal: "true", + expectedSupport: gh.ProjectsV1Supported, + }, + { + name: "PROJECTS_V1_SUPPORTED=false", + envVal: "false", + expectedSupport: gh.ProjectsV1Unsupported, + }, + { + name: "PROJECTS_V1_SUPPORTED=nonboolean", + envVal: "nonboolean", + expectedErr: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PROJECTS_V1_SUPPORTED", tc.envVal) + + detector := detector{} + support, err := detector.ProjectsV1() + + if tc.expectedErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedSupport, support) + }) + } +} + +func TestProjectV1APINotSupported(t *testing.T) { + // Given the API does not include projects in the returned fields + reg := &httpmock.Registry{} + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + reg.Register( + httpmock.GraphQL(`query ProjectsV1FeatureDetection\b`), + httpmock.StringResponse(heredoc.Doc(`{ "data": { "Repository": { "fields": [] } } }`)), + ) + + // When we call the api to detect for ProjectV1 support + detector := detector{httpClient: httpClient} + actualSupport, err := detector.ProjectsV1() + + // Then it succeeds and reports ProjectV1 is not supported + require.NoError(t, err) + require.Equal(t, gh.ProjectsV1Unsupported, actualSupport) +} + +func TestProjectV1APISupported(t *testing.T) { + // Given the API includes projects in the returned fields + reg := &httpmock.Registry{} + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + reg.Register( + httpmock.GraphQL(`query ProjectsV1FeatureDetection\b`), + httpmock.StringResponse(heredoc.Doc(`{ "data": { "Repository": { "fields": [ {"name": "projects"} ] } } }`)), + ) + + // When we call the api to detect for ProjectV1 support + detector := detector{httpClient: httpClient} + actualSupport, err := detector.ProjectsV1() + + // Then it succeeds and reports ProjectV1 is supported + require.NoError(t, err) + require.Equal(t, gh.ProjectsV1Supported, actualSupport) +} diff --git a/internal/gh/projects.go b/internal/gh/projects.go new file mode 100644 index 00000000000..042a35843c6 --- /dev/null +++ b/internal/gh/projects.go @@ -0,0 +1,30 @@ +package gh + +// ProjectsV1Support provides type safety and readability around whether or not Projects v1 is supported +// by the targeted host. +// +// It is a sealed type to ensure that consumers must use the exported ProjectsV1Supported and ProjectsV1Unsupported +// variables to get an instance of the type. +type ProjectsV1Support interface { + sealed() +} + +type projectsV1Supported struct{} + +func (projectsV1Supported) sealed() {} + +type projectsV1Unsupported struct{} + +func (projectsV1Unsupported) sealed() {} + +var ( + ProjectsV1Supported ProjectsV1Support = projectsV1Supported{} + ProjectsV1Unsupported ProjectsV1Support = projectsV1Unsupported{} +) + +func ParseProjectsV1Support(b bool) ProjectsV1Support { + if b { + return ProjectsV1Supported + } + return ProjectsV1Unsupported +} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 89d6df6b66a..e6c28cce2d9 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -154,7 +154,7 @@ func createRun(opts *CreateOptions) (err error) { opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) } - includeProjectV1, err := opts.Detector.ProjectV1() + projectsV1Support, err := opts.Detector.ProjectsV1() if err != nil { return err } @@ -195,7 +195,7 @@ func createRun(opts *CreateOptions) (err error) { if opts.WebMode { var openURL string if opts.Title != "" || opts.Body != "" || tb.HasMetadata() { - openURL, err = generatePreviewURL(apiClient, baseRepo, tb, includeProjectV1) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return } @@ -273,7 +273,7 @@ func createRun(opts *CreateOptions) (err error) { } } - openURL, err = generatePreviewURL(apiClient, baseRepo, tb, includeProjectV1) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return } @@ -292,7 +292,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, includeProjectV1) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support) if err != nil { return } @@ -348,7 +348,7 @@ func createRun(opts *CreateOptions) (err error) { params["issueTemplate"] = templateNameForSubmit } - err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, includeProjectV1) + err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, projectsV1Support) if err != nil { return } @@ -367,7 +367,7 @@ func createRun(opts *CreateOptions) (err error) { return } -func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, includeProjectV1 bool) (string, error) { +func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, includeProjectV1) + return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index c6d8a76330f..42bbbb340c2 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" 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/text" shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -29,7 +30,7 @@ type EditOptions struct { DetermineEditor func() (string, error) FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error - FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable, bool) error + FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable, gh.ProjectsV1Support) error SelectorArgs []string Interactive bool @@ -180,7 +181,7 @@ func editRun(opts *EditOptions) error { opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) } - includeProjectV1, err := opts.Detector.ProjectV1() + projectsV1Support, err := opts.Detector.ProjectsV1() if err != nil { return err } @@ -218,7 +219,7 @@ func editRun(opts *EditOptions) error { // Fetch editable shared fields once for all issues. apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicatorWithLabel("Fetching repository information") - err = opts.FetchOptions(apiClient, repo, &editable, includeProjectV1) + err = opts.FetchOptions(apiClient, repo, &editable, projectsV1Support) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index fbe9787e6af..c80759aef87 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/api" 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/pkg/set" "golang.org/x/sync/errgroup" @@ -167,11 +168,11 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f fieldSet.Add("number") } if fieldSet.Contains("projectCards") { - includeProjectV1, err := detector.ProjectV1() + projectsV1Support, err := detector.ProjectsV1() if err != nil { return nil, err } - if !includeProjectV1 { + if projectsV1Support == gh.ProjectsV1Unsupported { fieldSet.Remove("projectCards") } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0e9d6cd97c3..40a34e8fa1a 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -88,7 +88,7 @@ type CreateContext struct { IsPushEnabled bool Client *api.Client GitClient *git.Client - IncludeProjectV1 bool + ProjectsV1Support gh.ProjectsV1Support } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -445,7 +445,7 @@ func createRun(opts *CreateOptions) error { Repo: ctx.BaseRepo, State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state, ctx.IncludeProjectV1) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state, ctx.ProjectsV1Support) if err != nil { return err } @@ -784,7 +784,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) } - includeProjectV1, err := opts.Detector.ProjectV1() + projectsV1Support, err := opts.Detector.ProjectsV1() if err != nil { return nil, err } @@ -801,7 +801,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { RepoContext: repoContext, Client: client, GitClient: gitClient, - IncludeProjectV1: includeProjectV1, + ProjectsV1Support: projectsV1Support, }, nil } @@ -834,7 +834,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return errors.New("pull request title must not be blank") } - err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state, ctx.IncludeProjectV1) + err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state, ctx.ProjectsV1Support) if err != nil { return err } @@ -1071,7 +1071,7 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str ctx.BaseRepo, "compare/%s...%s?expand=1", url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel)) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state, ctx.IncludeProjectV1) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state, ctx.ProjectsV1Support) if err != nil { return "", err } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c294c3bf6a5..49bb26ee2ee 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -237,13 +237,13 @@ func editRun(opts *EditOptions) error { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) } - includeProjectV1, err := opts.Detector.ProjectV1() + projectsV1Support, err := opts.Detector.ProjectsV1() if err != nil { return err } opts.IO.StartProgressIndicator() - err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, includeProjectV1) + err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, projectsV1Support) opts.IO.StopProgressIndicator() if err != nil { return err @@ -323,13 +323,13 @@ func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error } type EditableOptionsFetcher interface { - EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable, bool) error + EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable, gh.ProjectsV1Support) error } type fetcher struct{} -func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, includeProjectV1 bool) error { - return shared.FetchOptions(client, repo, opts, includeProjectV1) +func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, projectsV1Support gh.ProjectsV1Support) error { + return shared.FetchOptions(client, repo, opts, projectsV1Support) } type EditorRetriever interface { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index f17626533ee..e64e039671e 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -663,8 +664,8 @@ type testSurveyor struct { } type testEditorRetriever struct{} -func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, includeProjectV1 bool) error { - return shared.FetchOptions(client, repo, opts, includeProjectV1) +func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, projectsV1Support gh.ProjectsV1Support) error { + return shared.FetchOptions(client, repo, opts, projectsV1Support) } func (s testSurveyor) FieldsToEdit(e *shared.Editable) error { diff --git a/pkg/cmd/pr/shared/completion.go b/pkg/cmd/pr/shared/completion.go index 20a5e3ac17a..99bdae406d5 100644 --- a/pkg/cmd/pr/shared/completion.go +++ b/pkg/cmd/pr/shared/completion.go @@ -8,13 +8,14 @@ import ( "time" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" ) func RequestableReviewersForCompletion(httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { client := api.NewClientFromHTTP(api.NewCachedHTTPClient(httpClient, time.Minute*2)) - metadata, err := api.RepoMetadata(client, repo, api.RepoMetadataInput{Reviewers: true}, false) + metadata, err := api.RepoMetadata(client, repo, api.RepoMetadataInput{Reviewers: true}, gh.ProjectsV1Unsupported) if err != nil { return nil, err } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 96bff7a18c2..072cf5bd9ac 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/set" ) @@ -376,7 +377,7 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { return nil } -func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, includeProjectV1 bool) error { +func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, projectsV1Support gh.ProjectsV1Support) error { input := api.RepoMetadataInput{ Reviewers: editable.Reviewers.Edited, Assignees: editable.Assignees.Edited, @@ -384,7 +385,7 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, Projects: editable.Projects.Edited, Milestones: editable.Milestone.Edited, } - metadata, err := api.RepoMetadata(client, repo, input, includeProjectV1) + metadata, err := api.RepoMetadata(client, repo, input, projectsV1Support) if err != nil { return err } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index e32c9953076..57888574d4f 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -16,6 +16,7 @@ import ( remotes "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" 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/pkg/cmdutil" "github.com/cli/cli/v2/pkg/set" @@ -249,11 +250,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields.Remove("projectItems") } if fields.Contains("projectCards") { - includeProjectV1, err := opts.Detector.ProjectV1() + projectsV1Support, err := opts.Detector.ProjectsV1() if err != nil { return nil, nil, err } - if !includeProjectV1 { + if projectsV1Support == gh.ProjectsV1Unsupported { fields.Remove("projectCards") } } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 022add0430f..b96ff57f4ef 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -6,12 +6,13 @@ import ( "strings" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/search" "github.com/google/shlex" ) -func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, includeProjectV1 bool) (string, error) { +func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) { u, err := url.Parse(baseURL) if err != nil { return "", err @@ -35,7 +36,7 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba q.Set("labels", strings.Join(state.Labels, ",")) } if len(state.Projects) > 0 { - projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects, includeProjectV1) + projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects, projectsV1Support) if err != nil { return "", fmt.Errorf("could not add to project: %w", err) } @@ -56,7 +57,7 @@ func ValidURL(urlStr string) bool { // Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able // to resolve all object listed in tb to GraphQL IDs. -func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, includeProjectV1 bool) error { +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error { resolveInput := api.RepoResolveInput{} if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { @@ -79,7 +80,7 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada resolveInput.Milestones = tb.Milestones } - metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput, includeProjectV1) + metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput, projectsV1Support) if err != nil { return err } @@ -93,12 +94,12 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada return nil } -func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, includeProjectV1 bool) error { +func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error { if !tb.HasMetadata() { return nil } - if err := fillMetadata(client, baseRepo, tb, includeProjectV1); err != nil { + if err := fillMetadata(client, baseRepo, tb, projectsV1Support); err != nil { return err } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index b6c8fd72c39..fe02649d87e 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -265,7 +266,7 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state, false) + got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state, gh.ProjectsV1Unsupported) if (err != nil) != tt.wantErr { t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index d3207003ab3..e2e1635769b 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -139,19 +139,19 @@ type MetadataFetcher struct { State *IssueMetadataState } -func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, includeProjectV1 bool) (*api.RepoMetadataResult, error) { +func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, projectsV1Support gh.ProjectsV1Support) (*api.RepoMetadataResult, error) { mf.IO.StartProgressIndicator() - metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input, includeProjectV1) + metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input, projectsV1Support) mf.IO.StopProgressIndicator() mf.State.MetadataResult = metadataResult return metadataResult, err } type RepoMetadataFetcher interface { - RepoMetadataFetch(api.RepoMetadataInput, bool) (*api.RepoMetadataResult, error) + RepoMetadataFetch(api.RepoMetadataInput, gh.ProjectsV1Support) (*api.RepoMetadataResult, error) } -func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, includeProjectV1 bool) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -184,7 +184,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - metadataResult, err := fetcher.RepoMetadataFetch(metadataInput, includeProjectV1) + metadataResult, err := fetcher.RepoMetadataFetch(metadataInput, projectsV1Support) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 3835a2c001b..4a76b8032ac 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" @@ -14,7 +15,7 @@ type metadataFetcher struct { metadataResult *api.RepoMetadataResult } -func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, includeProjectV1 bool) (*api.RepoMetadataResult, error) { +func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput, projectsV1Support gh.ProjectsV1Support) (*api.RepoMetadataResult, error) { return mf.metadataResult, nil } @@ -68,7 +69,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { Assignees: []string{"hubot"}, Type: PRMetadata, } - err := MetadataSurvey(pm, ios, repo, fetcher, state, true) + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -113,7 +114,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, } - err := MetadataSurvey(pm, ios, repo, fetcher, state, true) + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) From 0c2027c256267984056f6a6781058c7ab62ef6bd Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Fri, 23 Aug 2024 16:14:13 -0700 Subject: [PATCH 03/21] Add testing for printHumanIssuePreview around Projects output To satisfy the first AC in https://github.com/github/cli/issues/490, we require testing of the output and the feature detector. Seeing as the api will work for both v1 & v2 endpoints, the first step of is to ensure that the output isn't showing Projects when no projectCards (a v1 concept) are included in the issue it is meant to print. --- .../issueView_v1ProjectsDisabled.json | 25 ++++++++ .../fixtures/issueView_v1ProjectsEnabled.json | 39 ++++++++++++ pkg/cmd/issue/view/view_test.go | 61 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json create mode 100644 pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json diff --git a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json new file mode 100644 index 00000000000..e176b1be2ad --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json @@ -0,0 +1,25 @@ +{ + "number": 123, + "body": "**bold story**", + "title": "ix of coins", + "state": "OPEN", + "createdAt": "2011-01-26T19:01:12Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "milestone": { + "title": "" + }, + "comments": { + "totalCount": 9 + }, + "url": "https://github.com/OWNER/REPO/issues/123" +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json new file mode 100644 index 00000000000..41e82dd2738 --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json @@ -0,0 +1,39 @@ +{ + "number": 123, + "body": "**bold story**", + "title": "ix of coins", + "state": "OPEN", + "createdAt": "2011-01-26T19:01:12Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "projectcards": { + "nodes": [ + { + "project": { + "name": "Project 1" + }, + "column": { + "name": "Column name" + } + } + ], + "totalCount": 1 + }, + "milestone": { + "title": "" + }, + "comments": { + "totalCount": 9 + }, + "url": "https://github.com/OWNER/REPO/issues/123" +} + diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 6847d9178c6..c4d57ee203b 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,12 +2,15 @@ package view import ( "bytes" + "encoding/json" "fmt" "io" "net/http" + "os" "testing" "time" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -495,3 +498,61 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } + +func Test_printHumanIssuePreview(t *testing.T) { + tests := map[string]struct { + opts *ViewOptions + baseRepo ghrepo.Interface + issueFixture string + expectedOutput string + }{ + "projectcards included (v1 projects are supported)": { + opts: &ViewOptions{}, + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + "projectcards aren't included (v1 projects are supported)": { + opts: &ViewOptions{}, + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + tc.opts.IO = ios + + issue := &api.Issue{} + + f, err := os.Open(tc.issueFixture) + if err != nil { + t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) + } + defer f.Close() + + dec := json.NewDecoder(f) + err = dec.Decode(&issue) + if err != nil { + t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) + } + + // This is a hack to fix the time for outputs + tc.opts.Now = func() time.Time { + return issue.CreatedAt.Add(24 * time.Hour) + } + + err = printHumanIssuePreview(tc.opts, tc.baseRepo, issue) + if err != nil { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} From ac66c5044f17ae96c207931c212e2da12f956a85 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Mon, 26 Aug 2024 14:39:31 -0700 Subject: [PATCH 04/21] Refactor issue/view/view.go to introduce IssuePrint struct The entire tty printing of this command was tested through fragile blocks of strings that made any sort of testing or refactoring difficult, as I found in the previous commit. I found myself iterating back and forth with the tests and the code in order to make sure my expected output was just right to match what the code was doing. To simplify this, I've introduced the IssuePrint struct, which contains methods for each of the individual pieces of an issue that will be printed. This not only makes understanding what the terminal output will look like easier, but allows us to both test these functions individually (which I'll do in a forthcoming commit) and mock them for simpler testing of the larger command. In this commit, I have not added any functions beyond changing them to work with the new struct. All tests are green. In addition, there were a few minor bugfixes that this new format found, such as a nil pointer issue in the milestone printing section, that have been fixed as well. --- pkg/cmd/issue/view/view.go | 247 +++++++++++++++++++------------- pkg/cmd/issue/view/view_test.go | 11 +- 2 files changed, 161 insertions(+), 97 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index c0a8c294c98..5730cc81d52 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -3,7 +3,6 @@ package view import ( "errors" "fmt" - "io" "net/http" "sort" "strings" @@ -133,8 +132,17 @@ func viewRun(opts *ViewOptions) error { return opts.Exporter.Write(opts.IO, issue) } + issuePrint := &IssuePrint{ + issue: issue, + colorScheme: opts.IO.ColorScheme(), + IO: opts.IO, + time: opts.Now(), + baseRepo: baseRepo, + } + if opts.IO.IsStdoutTTY() { - return printHumanIssuePreview(opts, baseRepo, issue) + isCommentsPreview := !opts.Comments + return issuePrint.humanPreview(isCommentsPreview) } if opts.Comments { @@ -142,7 +150,7 @@ func viewRun(opts *ViewOptions) error { return nil } - return printRawIssuePreview(opts.IO.Out, issue) + return issuePrint.rawPreview() } func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { @@ -163,133 +171,141 @@ func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), return issue, repo, err } -func printRawIssuePreview(out io.Writer, issue *api.Issue) error { - assignees := issueAssigneeList(*issue) - labels := issueLabelList(issue, nil) - projects := issueProjectList(*issue) +type IssuePrint struct { + issue *api.Issue + colorScheme *iostreams.ColorScheme + IO *iostreams.IOStreams + time time.Time + baseRepo ghrepo.Interface +} + +func (i *IssuePrint) rawPreview() error { + assignees := i.getAssigneeList() + labels := i.getLabelList() + projects := i.getProjectList() // Print empty strings for empty values so the number of metadata lines is consistent when // processing many issues with head and grep. - fmt.Fprintf(out, "title:\t%s\n", issue.Title) - fmt.Fprintf(out, "state:\t%s\n", issue.State) - fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "projects:\t%s\n", projects) + fmt.Fprintf(i.IO.Out, "title:\t%s\n", i.issue.Title) + fmt.Fprintf(i.IO.Out, "state:\t%s\n", i.issue.State) + fmt.Fprintf(i.IO.Out, "author:\t%s\n", i.issue.Author.Login) + fmt.Fprintf(i.IO.Out, "labels:\t%s\n", labels) + fmt.Fprintf(i.IO.Out, "comments:\t%d\n", i.issue.Comments.TotalCount) + fmt.Fprintf(i.IO.Out, "assignees:\t%s\n", assignees) + fmt.Fprintf(i.IO.Out, "projects:\t%s\n", projects) var milestoneTitle string - if issue.Milestone != nil { - milestoneTitle = issue.Milestone.Title + if i.issue.Milestone != nil { + milestoneTitle = i.issue.Milestone.Title } - fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) - fmt.Fprintf(out, "number:\t%d\n", issue.Number) - fmt.Fprintln(out, "--") - fmt.Fprintln(out, issue.Body) + fmt.Fprintf(i.IO.Out, "milestone:\t%s\n", milestoneTitle) + fmt.Fprintf(i.IO.Out, "number:\t%d\n", i.issue.Number) + fmt.Fprintln(i.IO.Out, "--") + fmt.Fprintln(i.IO.Out, i.issue.Body) return nil } -func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue *api.Issue) error { - out := opts.IO.Out - cs := opts.IO.ColorScheme() - - // Header (Title and State) - fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(issue.Title), ghrepo.FullName(baseRepo), issue.Number) - fmt.Fprintf(out, - "%s • %s opened %s • %s\n", - issueStateTitleWithColor(cs, issue), - issue.Author.Login, - text.FuzzyAgo(opts.Now(), issue.CreatedAt), - text.Pluralize(issue.Comments.TotalCount, "comment"), - ) +func (i *IssuePrint) humanPreview(isCommentsPreview bool) error { + // header (Title and State) + i.header() // Reactions - if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" { - fmt.Fprint(out, reactions) - fmt.Fprintln(out) - } - + i.reactions() // Metadata - if assignees := issueAssigneeList(*issue); assignees != "" { - fmt.Fprint(out, cs.Bold("Assignees: ")) - fmt.Fprintln(out, assignees) - } - if labels := issueLabelList(issue, cs); labels != "" { - fmt.Fprint(out, cs.Bold("Labels: ")) - fmt.Fprintln(out, labels) - } - if projects := issueProjectList(*issue); projects != "" { - fmt.Fprint(out, cs.Bold("Projects: ")) - fmt.Fprintln(out, projects) - } - if issue.Milestone != nil { - fmt.Fprint(out, cs.Bold("Milestone: ")) - fmt.Fprintln(out, issue.Milestone.Title) - } + i.assigneeList() + i.labelList() + i.projectList() + i.milestone() // Body - var md string - var err error - if issue.Body == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) - } else { - md, err = markdown.Render(issue.Body, - markdown.WithTheme(opts.IO.TerminalTheme()), - markdown.WithWrap(opts.IO.TerminalWidth())) - if err != nil { - return err - } + err := i.body() + if err != nil { + return err } - fmt.Fprintf(out, "\n%s\n", md) // Comments - if issue.Comments.TotalCount > 0 { - preview := !opts.Comments - comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview) - if err != nil { - return err - } - fmt.Fprint(out, comments) + err = i.comments(isCommentsPreview) + if err != nil { + return err } // Footer - fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) + i.footer() return nil } -func issueStateTitleWithColor(cs *iostreams.ColorScheme, issue *api.Issue) string { - colorFunc := cs.ColorFromString(prShared.ColorForIssueState(*issue)) +func (i *IssuePrint) header() { + fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) + fmt.Fprintf(i.IO.Out, + "%s • %s opened %s • %s\n", + i.issueStateTitleWithColor(), + i.issue.Author.Login, + text.FuzzyAgo(i.time, i.issue.CreatedAt), + text.Pluralize(i.issue.Comments.TotalCount, "comment"), + ) +} + +func (i *IssuePrint) issueStateTitleWithColor() string { + colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(*i.issue)) state := "Open" - if issue.State == "CLOSED" { + if i.issue.State == "CLOSED" { state = "Closed" } return colorFunc(state) } -func issueAssigneeList(issue api.Issue) string { - if len(issue.Assignees.Nodes) == 0 { +func (i *IssuePrint) reactions() { + if reactions := prShared.ReactionGroupList(i.issue.ReactionGroups); reactions != "" { + fmt.Fprint(i.IO.Out, reactions) + fmt.Fprintln(i.IO.Out) + } +} + +func (i *IssuePrint) assigneeList() { + if assignees := i.getAssigneeList(); assignees != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) + fmt.Fprintln(i.IO.Out, assignees) + } +} + +func (i *IssuePrint) getAssigneeList() string { + if len(i.issue.Assignees.Nodes) == 0 { return "" } - AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) - for _, assignee := range issue.Assignees.Nodes { + AssigneeNames := make([]string, 0, len(i.issue.Assignees.Nodes)) + for _, assignee := range i.issue.Assignees.Nodes { AssigneeNames = append(AssigneeNames, assignee.Login) } list := strings.Join(AssigneeNames, ", ") - if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { + if i.issue.Assignees.TotalCount > len(i.issue.Assignees.Nodes) { list += ", …" } return list } -func issueProjectList(issue api.Issue) string { - if len(issue.ProjectCards.Nodes) == 0 { +func (i *IssuePrint) labelList() { + if labels := i.getLabelList(); labels != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) + fmt.Fprintln(i.IO.Out, labels) + } +} + +func (i *IssuePrint) projectList() { + if projects := i.getProjectList(); projects != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) + fmt.Fprintln(i.IO.Out, projects) + } +} + +func (i *IssuePrint) getProjectList() string { + if len(i.issue.ProjectCards.Nodes) == 0 { return "" } - projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) - for _, project := range issue.ProjectCards.Nodes { + projectNames := make([]string, 0, len(i.issue.ProjectCards.Nodes)) + for _, project := range i.issue.ProjectCards.Nodes { colName := project.Column.Name if colName == "" { colName = "Awaiting triage" @@ -298,30 +314,69 @@ func issueProjectList(issue api.Issue) string { } list := strings.Join(projectNames, ", ") - if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { + if i.issue.ProjectCards.TotalCount > len(i.issue.ProjectCards.Nodes) { list += ", …" } return list } -func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string { - if len(issue.Labels.Nodes) == 0 { +func (i *IssuePrint) getLabelList() string { + if len(i.issue.Labels.Nodes) == 0 { return "" } // ignore case sort - sort.SliceStable(issue.Labels.Nodes, func(i, j int) bool { - return strings.ToLower(issue.Labels.Nodes[i].Name) < strings.ToLower(issue.Labels.Nodes[j].Name) + sort.SliceStable(i.issue.Labels.Nodes, func(j, k int) bool { + return strings.ToLower(i.issue.Labels.Nodes[j].Name) < strings.ToLower(i.issue.Labels.Nodes[k].Name) }) - labelNames := make([]string, len(issue.Labels.Nodes)) - for i, label := range issue.Labels.Nodes { - if cs == nil { - labelNames[i] = label.Name + labelNames := make([]string, len(i.issue.Labels.Nodes)) + for j, label := range i.issue.Labels.Nodes { + if i.colorScheme == nil { + labelNames[j] = label.Name } else { - labelNames[i] = cs.HexToRGB(label.Color, label.Name) + labelNames[j] = i.colorScheme.HexToRGB(label.Color, label.Name) } } return strings.Join(labelNames, ", ") } + +func (i *IssuePrint) milestone() { + if i.issue.Milestone != nil { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) + fmt.Fprintln(i.IO.Out, i.issue.Milestone.Title) + } +} + +func (i *IssuePrint) body() error { + var md string + var err error + if i.issue.Body == "" { + md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) + } else { + md, err = markdown.Render(i.issue.Body, + markdown.WithTheme(i.IO.TerminalTheme()), + markdown.WithWrap(i.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(i.IO.Out, "\n%s\n", md) + return nil +} + +func (i *IssuePrint) comments(isPreview bool) error { + if i.issue.Comments.TotalCount > 0 { + comments, err := prShared.CommentList(i.IO, i.issue.Comments, api.PullRequestReviews{}, isPreview) + if err != nil { + return err + } + fmt.Fprint(i.IO.Out, comments) + } + return nil +} + +func (i *IssuePrint) footer() { + fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.issue.URL) +} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index c4d57ee203b..18171bbeeaa 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -499,6 +499,7 @@ func TestIssueView_nontty_Comments(t *testing.T) { } } +// Maybe I should be testing the issueProjectList function instead... func Test_printHumanIssuePreview(t *testing.T) { tests := map[string]struct { opts *ViewOptions @@ -548,7 +549,15 @@ func Test_printHumanIssuePreview(t *testing.T) { return issue.CreatedAt.Add(24 * time.Hour) } - err = printHumanIssuePreview(tc.opts, tc.baseRepo, issue) + issuePrint := &IssuePrint{ + issue: issue, + colorScheme: ios.ColorScheme(), + IO: ios, + time: tc.opts.Now(), + baseRepo: tc.baseRepo, + } + + err = issuePrint.humanPreview(!tc.opts.Comments) if err != nil { assert.NoError(t, err) } From 038d48d70223e358a5a6b5f6ecabb5de2eaacefa Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Mon, 26 Aug 2024 14:51:42 -0700 Subject: [PATCH 05/21] Move IssuePrint and tests to separate files Keeping these separate from the main view.go file will allow for easier navigation of the code and facilitate better testing practices. Note: all current tests are essentially integration tests because the interfaces between the functionality wasn't well defined. --- pkg/cmd/issue/view/issuePrint.go | 225 ++++++++++++++++++++++++++ pkg/cmd/issue/view/issuePrint_test.go | 80 +++++++++ pkg/cmd/issue/view/view.go | 213 ------------------------ pkg/cmd/issue/view/view_test.go | 70 -------- 4 files changed, 305 insertions(+), 283 deletions(-) create mode 100644 pkg/cmd/issue/view/issuePrint.go create mode 100644 pkg/cmd/issue/view/issuePrint_test.go diff --git a/pkg/cmd/issue/view/issuePrint.go b/pkg/cmd/issue/view/issuePrint.go new file mode 100644 index 00000000000..b89a74b3e9e --- /dev/null +++ b/pkg/cmd/issue/view/issuePrint.go @@ -0,0 +1,225 @@ +package view + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" +) + +type IssuePrint struct { + issue *api.Issue + colorScheme *iostreams.ColorScheme + IO *iostreams.IOStreams + time time.Time + baseRepo ghrepo.Interface +} + +func (i *IssuePrint) rawPreview() error { + assignees := i.getAssigneeList() + labels := i.getLabelList() + projects := i.getProjectList() + + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(i.IO.Out, "title:\t%s\n", i.issue.Title) + fmt.Fprintf(i.IO.Out, "state:\t%s\n", i.issue.State) + fmt.Fprintf(i.IO.Out, "author:\t%s\n", i.issue.Author.Login) + fmt.Fprintf(i.IO.Out, "labels:\t%s\n", labels) + fmt.Fprintf(i.IO.Out, "comments:\t%d\n", i.issue.Comments.TotalCount) + fmt.Fprintf(i.IO.Out, "assignees:\t%s\n", assignees) + fmt.Fprintf(i.IO.Out, "projects:\t%s\n", projects) + var milestoneTitle string + if i.issue.Milestone != nil { + milestoneTitle = i.issue.Milestone.Title + } + fmt.Fprintf(i.IO.Out, "milestone:\t%s\n", milestoneTitle) + fmt.Fprintf(i.IO.Out, "number:\t%d\n", i.issue.Number) + fmt.Fprintln(i.IO.Out, "--") + fmt.Fprintln(i.IO.Out, i.issue.Body) + return nil +} + +func (i *IssuePrint) humanPreview(isCommentsPreview bool) error { + + // header (Title and State) + i.header() + // Reactions + i.reactions() + // Metadata + i.assigneeList() + i.labelList() + i.projectList() + i.milestone() + + // Body + err := i.body() + if err != nil { + return err + } + + // Comments + err = i.comments(isCommentsPreview) + if err != nil { + return err + } + + // Footer + i.footer() + + return nil +} + +func (i *IssuePrint) header() { + fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) + fmt.Fprintf(i.IO.Out, + "%s • %s opened %s • %s\n", + i.issueStateTitleWithColor(), + i.issue.Author.Login, + text.FuzzyAgo(i.time, i.issue.CreatedAt), + text.Pluralize(i.issue.Comments.TotalCount, "comment"), + ) +} + +func (i *IssuePrint) issueStateTitleWithColor() string { + colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(*i.issue)) + state := "Open" + if i.issue.State == "CLOSED" { + state = "Closed" + } + return colorFunc(state) +} + +func (i *IssuePrint) reactions() { + if reactions := prShared.ReactionGroupList(i.issue.ReactionGroups); reactions != "" { + fmt.Fprint(i.IO.Out, reactions) + fmt.Fprintln(i.IO.Out) + } +} + +func (i *IssuePrint) assigneeList() { + if assignees := i.getAssigneeList(); assignees != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) + fmt.Fprintln(i.IO.Out, assignees) + } +} + +func (i *IssuePrint) getAssigneeList() string { + if len(i.issue.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(i.issue.Assignees.Nodes)) + for _, assignee := range i.issue.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if i.issue.Assignees.TotalCount > len(i.issue.Assignees.Nodes) { + list += ", …" + } + return list +} + +func (i *IssuePrint) labelList() { + if labels := i.getLabelList(); labels != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) + fmt.Fprintln(i.IO.Out, labels) + } +} + +func (i *IssuePrint) projectList() { + if projects := i.getProjectList(); projects != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) + fmt.Fprintln(i.IO.Out, projects) + } +} + +func (i *IssuePrint) getProjectList() string { + if len(i.issue.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(i.issue.ProjectCards.Nodes)) + for _, project := range i.issue.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if i.issue.ProjectCards.TotalCount > len(i.issue.ProjectCards.Nodes) { + list += ", …" + } + return list +} + +func (i *IssuePrint) getLabelList() string { + if len(i.issue.Labels.Nodes) == 0 { + return "" + } + + // ignore case sort + sort.SliceStable(i.issue.Labels.Nodes, func(j, k int) bool { + return strings.ToLower(i.issue.Labels.Nodes[j].Name) < strings.ToLower(i.issue.Labels.Nodes[k].Name) + }) + + labelNames := make([]string, len(i.issue.Labels.Nodes)) + for j, label := range i.issue.Labels.Nodes { + if i.colorScheme == nil { + labelNames[j] = label.Name + } else { + labelNames[j] = i.colorScheme.HexToRGB(label.Color, label.Name) + } + } + + return strings.Join(labelNames, ", ") +} + +func (i *IssuePrint) milestone() { + if i.issue.Milestone != nil { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) + fmt.Fprintln(i.IO.Out, i.issue.Milestone.Title) + } +} + +func (i *IssuePrint) body() error { + var md string + var err error + if i.issue.Body == "" { + md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) + } else { + md, err = markdown.Render(i.issue.Body, + markdown.WithTheme(i.IO.TerminalTheme()), + markdown.WithWrap(i.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(i.IO.Out, "\n%s\n", md) + return nil +} + +func (i *IssuePrint) comments(isPreview bool) error { + if i.issue.Comments.TotalCount > 0 { + comments, err := prShared.CommentList(i.IO, i.issue.Comments, api.PullRequestReviews{}, isPreview) + if err != nil { + return err + } + fmt.Fprint(i.IO.Out, comments) + } + return nil +} + +func (i *IssuePrint) footer() { + fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.issue.URL) +} diff --git a/pkg/cmd/issue/view/issuePrint_test.go b/pkg/cmd/issue/view/issuePrint_test.go new file mode 100644 index 00000000000..c6020280158 --- /dev/null +++ b/pkg/cmd/issue/view/issuePrint_test.go @@ -0,0 +1,80 @@ +package view + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +// Maybe I should be testing the issueProjectList function instead... +func Test_printHumanIssuePreview(t *testing.T) { + tests := map[string]struct { + opts *ViewOptions + baseRepo ghrepo.Interface + issueFixture string + expectedOutput string + }{ + "projectcards included (v1 projects are supported)": { + opts: &ViewOptions{}, + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + "projectcards aren't included (v1 projects are supported)": { + opts: &ViewOptions{}, + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + tc.opts.IO = ios + + issue := &api.Issue{} + + f, err := os.Open(tc.issueFixture) + if err != nil { + t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) + } + defer f.Close() + + dec := json.NewDecoder(f) + err = dec.Decode(&issue) + if err != nil { + t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) + } + + // This is a hack to fix the time for outputs + tc.opts.Now = func() time.Time { + return issue.CreatedAt.Add(24 * time.Hour) + } + + issuePrint := &IssuePrint{ + issue: issue, + colorScheme: ios.ColorScheme(), + IO: ios, + time: tc.opts.Now(), + baseRepo: tc.baseRepo, + } + + err = issuePrint.humanPreview(!tc.opts.Comments) + if err != nil { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 5730cc81d52..e58194fa197 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -4,8 +4,6 @@ import ( "errors" "fmt" "net/http" - "sort" - "strings" "time" "github.com/MakeNowJust/heredoc" @@ -18,7 +16,6 @@ import ( prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" "github.com/cli/cli/v2/pkg/set" "github.com/spf13/cobra" ) @@ -170,213 +167,3 @@ func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), } return issue, repo, err } - -type IssuePrint struct { - issue *api.Issue - colorScheme *iostreams.ColorScheme - IO *iostreams.IOStreams - time time.Time - baseRepo ghrepo.Interface -} - -func (i *IssuePrint) rawPreview() error { - assignees := i.getAssigneeList() - labels := i.getLabelList() - projects := i.getProjectList() - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(i.IO.Out, "title:\t%s\n", i.issue.Title) - fmt.Fprintf(i.IO.Out, "state:\t%s\n", i.issue.State) - fmt.Fprintf(i.IO.Out, "author:\t%s\n", i.issue.Author.Login) - fmt.Fprintf(i.IO.Out, "labels:\t%s\n", labels) - fmt.Fprintf(i.IO.Out, "comments:\t%d\n", i.issue.Comments.TotalCount) - fmt.Fprintf(i.IO.Out, "assignees:\t%s\n", assignees) - fmt.Fprintf(i.IO.Out, "projects:\t%s\n", projects) - var milestoneTitle string - if i.issue.Milestone != nil { - milestoneTitle = i.issue.Milestone.Title - } - fmt.Fprintf(i.IO.Out, "milestone:\t%s\n", milestoneTitle) - fmt.Fprintf(i.IO.Out, "number:\t%d\n", i.issue.Number) - fmt.Fprintln(i.IO.Out, "--") - fmt.Fprintln(i.IO.Out, i.issue.Body) - return nil -} - -func (i *IssuePrint) humanPreview(isCommentsPreview bool) error { - - // header (Title and State) - i.header() - // Reactions - i.reactions() - // Metadata - i.assigneeList() - i.labelList() - i.projectList() - i.milestone() - - // Body - err := i.body() - if err != nil { - return err - } - - // Comments - err = i.comments(isCommentsPreview) - if err != nil { - return err - } - - // Footer - i.footer() - - return nil -} - -func (i *IssuePrint) header() { - fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) - fmt.Fprintf(i.IO.Out, - "%s • %s opened %s • %s\n", - i.issueStateTitleWithColor(), - i.issue.Author.Login, - text.FuzzyAgo(i.time, i.issue.CreatedAt), - text.Pluralize(i.issue.Comments.TotalCount, "comment"), - ) -} - -func (i *IssuePrint) issueStateTitleWithColor() string { - colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(*i.issue)) - state := "Open" - if i.issue.State == "CLOSED" { - state = "Closed" - } - return colorFunc(state) -} - -func (i *IssuePrint) reactions() { - if reactions := prShared.ReactionGroupList(i.issue.ReactionGroups); reactions != "" { - fmt.Fprint(i.IO.Out, reactions) - fmt.Fprintln(i.IO.Out) - } -} - -func (i *IssuePrint) assigneeList() { - if assignees := i.getAssigneeList(); assignees != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) - fmt.Fprintln(i.IO.Out, assignees) - } -} - -func (i *IssuePrint) getAssigneeList() string { - if len(i.issue.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(i.issue.Assignees.Nodes)) - for _, assignee := range i.issue.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if i.issue.Assignees.TotalCount > len(i.issue.Assignees.Nodes) { - list += ", …" - } - return list -} - -func (i *IssuePrint) labelList() { - if labels := i.getLabelList(); labels != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) - fmt.Fprintln(i.IO.Out, labels) - } -} - -func (i *IssuePrint) projectList() { - if projects := i.getProjectList(); projects != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) - fmt.Fprintln(i.IO.Out, projects) - } -} - -func (i *IssuePrint) getProjectList() string { - if len(i.issue.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(i.issue.ProjectCards.Nodes)) - for _, project := range i.issue.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if i.issue.ProjectCards.TotalCount > len(i.issue.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func (i *IssuePrint) getLabelList() string { - if len(i.issue.Labels.Nodes) == 0 { - return "" - } - - // ignore case sort - sort.SliceStable(i.issue.Labels.Nodes, func(j, k int) bool { - return strings.ToLower(i.issue.Labels.Nodes[j].Name) < strings.ToLower(i.issue.Labels.Nodes[k].Name) - }) - - labelNames := make([]string, len(i.issue.Labels.Nodes)) - for j, label := range i.issue.Labels.Nodes { - if i.colorScheme == nil { - labelNames[j] = label.Name - } else { - labelNames[j] = i.colorScheme.HexToRGB(label.Color, label.Name) - } - } - - return strings.Join(labelNames, ", ") -} - -func (i *IssuePrint) milestone() { - if i.issue.Milestone != nil { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) - fmt.Fprintln(i.IO.Out, i.issue.Milestone.Title) - } -} - -func (i *IssuePrint) body() error { - var md string - var err error - if i.issue.Body == "" { - md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) - } else { - md, err = markdown.Render(i.issue.Body, - markdown.WithTheme(i.IO.TerminalTheme()), - markdown.WithWrap(i.IO.TerminalWidth())) - if err != nil { - return err - } - } - fmt.Fprintf(i.IO.Out, "\n%s\n", md) - return nil -} - -func (i *IssuePrint) comments(isPreview bool) error { - if i.issue.Comments.TotalCount > 0 { - comments, err := prShared.CommentList(i.IO, i.issue.Comments, api.PullRequestReviews{}, isPreview) - if err != nil { - return err - } - fmt.Fprint(i.IO.Out, comments) - } - return nil -} - -func (i *IssuePrint) footer() { - fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.issue.URL) -} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 18171bbeeaa..6847d9178c6 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,15 +2,12 @@ package view import ( "bytes" - "encoding/json" "fmt" "io" "net/http" - "os" "testing" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -498,70 +495,3 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } - -// Maybe I should be testing the issueProjectList function instead... -func Test_printHumanIssuePreview(t *testing.T) { - tests := map[string]struct { - opts *ViewOptions - baseRepo ghrepo.Interface - issueFixture string - expectedOutput string - }{ - "projectcards included (v1 projects are supported)": { - opts: &ViewOptions{}, - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - "projectcards aren't included (v1 projects are supported)": { - opts: &ViewOptions{}, - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - tc.opts.IO = ios - - issue := &api.Issue{} - - f, err := os.Open(tc.issueFixture) - if err != nil { - t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) - } - defer f.Close() - - dec := json.NewDecoder(f) - err = dec.Decode(&issue) - if err != nil { - t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) - } - - // This is a hack to fix the time for outputs - tc.opts.Now = func() time.Time { - return issue.CreatedAt.Add(24 * time.Hour) - } - - issuePrint := &IssuePrint{ - issue: issue, - colorScheme: ios.ColorScheme(), - IO: ios, - time: tc.opts.Now(), - baseRepo: tc.baseRepo, - } - - err = issuePrint.humanPreview(!tc.opts.Comments) - if err != nil { - assert.NoError(t, err) - } - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} From 49aa2128326eb81edf10c2d97318bd861d902313 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Mon, 26 Aug 2024 15:34:03 -0700 Subject: [PATCH 06/21] Add test for rawIssuePreview and refactor Upon adding a test for rawIssuePreview, I realized that the IssuePrint interface was getting overloaded. Thus, I pulled the humanIssuePreview and rawIssuePreview functionality out of it, scoping it down to just formatting the outputs for the human readable issues and renaming it to IssuePrintFormatter. Notably, I think getAssigneeList, getLabelList, and getProjectList are all in the wrong place... It seems to me like they may belong on api.Issue itself, though that touches something way deeper in the codebase. They will live on the IssuePrintFormatter interface for now, but I'll probably want to move them somewhere else before long. --- pkg/cmd/issue/view/issuePrint.go | 83 ++++--------------- pkg/cmd/issue/view/issuePrint_test.go | 80 ------------------ pkg/cmd/issue/view/view.go | 68 ++++++++++++++- pkg/cmd/issue/view/view_test.go | 114 ++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 152 deletions(-) delete mode 100644 pkg/cmd/issue/view/issuePrint_test.go diff --git a/pkg/cmd/issue/view/issuePrint.go b/pkg/cmd/issue/view/issuePrint.go index b89a74b3e9e..9a20f3c1740 100644 --- a/pkg/cmd/issue/view/issuePrint.go +++ b/pkg/cmd/issue/view/issuePrint.go @@ -14,7 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/markdown" ) -type IssuePrint struct { +type IssuePrintFormatter struct { issue *api.Issue colorScheme *iostreams.ColorScheme IO *iostreams.IOStreams @@ -22,62 +22,7 @@ type IssuePrint struct { baseRepo ghrepo.Interface } -func (i *IssuePrint) rawPreview() error { - assignees := i.getAssigneeList() - labels := i.getLabelList() - projects := i.getProjectList() - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(i.IO.Out, "title:\t%s\n", i.issue.Title) - fmt.Fprintf(i.IO.Out, "state:\t%s\n", i.issue.State) - fmt.Fprintf(i.IO.Out, "author:\t%s\n", i.issue.Author.Login) - fmt.Fprintf(i.IO.Out, "labels:\t%s\n", labels) - fmt.Fprintf(i.IO.Out, "comments:\t%d\n", i.issue.Comments.TotalCount) - fmt.Fprintf(i.IO.Out, "assignees:\t%s\n", assignees) - fmt.Fprintf(i.IO.Out, "projects:\t%s\n", projects) - var milestoneTitle string - if i.issue.Milestone != nil { - milestoneTitle = i.issue.Milestone.Title - } - fmt.Fprintf(i.IO.Out, "milestone:\t%s\n", milestoneTitle) - fmt.Fprintf(i.IO.Out, "number:\t%d\n", i.issue.Number) - fmt.Fprintln(i.IO.Out, "--") - fmt.Fprintln(i.IO.Out, i.issue.Body) - return nil -} - -func (i *IssuePrint) humanPreview(isCommentsPreview bool) error { - - // header (Title and State) - i.header() - // Reactions - i.reactions() - // Metadata - i.assigneeList() - i.labelList() - i.projectList() - i.milestone() - - // Body - err := i.body() - if err != nil { - return err - } - - // Comments - err = i.comments(isCommentsPreview) - if err != nil { - return err - } - - // Footer - i.footer() - - return nil -} - -func (i *IssuePrint) header() { +func (i *IssuePrintFormatter) header() { fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) fmt.Fprintf(i.IO.Out, "%s • %s opened %s • %s\n", @@ -88,7 +33,7 @@ func (i *IssuePrint) header() { ) } -func (i *IssuePrint) issueStateTitleWithColor() string { +func (i *IssuePrintFormatter) issueStateTitleWithColor() string { colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(*i.issue)) state := "Open" if i.issue.State == "CLOSED" { @@ -97,21 +42,21 @@ func (i *IssuePrint) issueStateTitleWithColor() string { return colorFunc(state) } -func (i *IssuePrint) reactions() { +func (i *IssuePrintFormatter) reactions() { if reactions := prShared.ReactionGroupList(i.issue.ReactionGroups); reactions != "" { fmt.Fprint(i.IO.Out, reactions) fmt.Fprintln(i.IO.Out) } } -func (i *IssuePrint) assigneeList() { +func (i *IssuePrintFormatter) assigneeList() { if assignees := i.getAssigneeList(); assignees != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) fmt.Fprintln(i.IO.Out, assignees) } } -func (i *IssuePrint) getAssigneeList() string { +func (i *IssuePrintFormatter) getAssigneeList() string { if len(i.issue.Assignees.Nodes) == 0 { return "" } @@ -128,21 +73,21 @@ func (i *IssuePrint) getAssigneeList() string { return list } -func (i *IssuePrint) labelList() { +func (i *IssuePrintFormatter) labelList() { if labels := i.getLabelList(); labels != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) fmt.Fprintln(i.IO.Out, labels) } } -func (i *IssuePrint) projectList() { +func (i *IssuePrintFormatter) projectList() { if projects := i.getProjectList(); projects != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) fmt.Fprintln(i.IO.Out, projects) } } -func (i *IssuePrint) getProjectList() string { +func (i *IssuePrintFormatter) getProjectList() string { if len(i.issue.ProjectCards.Nodes) == 0 { return "" } @@ -163,7 +108,7 @@ func (i *IssuePrint) getProjectList() string { return list } -func (i *IssuePrint) getLabelList() string { +func (i *IssuePrintFormatter) getLabelList() string { if len(i.issue.Labels.Nodes) == 0 { return "" } @@ -185,14 +130,14 @@ func (i *IssuePrint) getLabelList() string { return strings.Join(labelNames, ", ") } -func (i *IssuePrint) milestone() { +func (i *IssuePrintFormatter) milestone() { if i.issue.Milestone != nil { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) fmt.Fprintln(i.IO.Out, i.issue.Milestone.Title) } } -func (i *IssuePrint) body() error { +func (i *IssuePrintFormatter) body() error { var md string var err error if i.issue.Body == "" { @@ -209,7 +154,7 @@ func (i *IssuePrint) body() error { return nil } -func (i *IssuePrint) comments(isPreview bool) error { +func (i *IssuePrintFormatter) comments(isPreview bool) error { if i.issue.Comments.TotalCount > 0 { comments, err := prShared.CommentList(i.IO, i.issue.Comments, api.PullRequestReviews{}, isPreview) if err != nil { @@ -220,6 +165,6 @@ func (i *IssuePrint) comments(isPreview bool) error { return nil } -func (i *IssuePrint) footer() { +func (i *IssuePrintFormatter) footer() { fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.issue.URL) } diff --git a/pkg/cmd/issue/view/issuePrint_test.go b/pkg/cmd/issue/view/issuePrint_test.go deleted file mode 100644 index c6020280158..00000000000 --- a/pkg/cmd/issue/view/issuePrint_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package view - -import ( - "encoding/json" - "os" - "testing" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/assert" -) - -// Maybe I should be testing the issueProjectList function instead... -func Test_printHumanIssuePreview(t *testing.T) { - tests := map[string]struct { - opts *ViewOptions - baseRepo ghrepo.Interface - issueFixture string - expectedOutput string - }{ - "projectcards included (v1 projects are supported)": { - opts: &ViewOptions{}, - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - "projectcards aren't included (v1 projects are supported)": { - opts: &ViewOptions{}, - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - tc.opts.IO = ios - - issue := &api.Issue{} - - f, err := os.Open(tc.issueFixture) - if err != nil { - t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) - } - defer f.Close() - - dec := json.NewDecoder(f) - err = dec.Decode(&issue) - if err != nil { - t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) - } - - // This is a hack to fix the time for outputs - tc.opts.Now = func() time.Time { - return issue.CreatedAt.Add(24 * time.Hour) - } - - issuePrint := &IssuePrint{ - issue: issue, - colorScheme: ios.ColorScheme(), - IO: ios, - time: tc.opts.Now(), - baseRepo: tc.baseRepo, - } - - err = issuePrint.humanPreview(!tc.opts.Comments) - if err != nil { - assert.NoError(t, err) - } - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index e58194fa197..a6b1cd75f35 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -129,7 +129,7 @@ func viewRun(opts *ViewOptions) error { return opts.Exporter.Write(opts.IO, issue) } - issuePrint := &IssuePrint{ + ipf := &IssuePrintFormatter{ issue: issue, colorScheme: opts.IO.ColorScheme(), IO: opts.IO, @@ -139,7 +139,7 @@ func viewRun(opts *ViewOptions) error { if opts.IO.IsStdoutTTY() { isCommentsPreview := !opts.Comments - return issuePrint.humanPreview(isCommentsPreview) + return humanIssuePreview(ipf, isCommentsPreview) } if opts.Comments { @@ -147,7 +147,7 @@ func viewRun(opts *ViewOptions) error { return nil } - return issuePrint.rawPreview() + return rawIssuePreview(opts.IO, issue) } func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { @@ -167,3 +167,65 @@ func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), } return issue, repo, err } + +func rawIssuePreview(IO *iostreams.IOStreams, issue *api.Issue) error { + + out := IO.Out + + ipf := &IssuePrintFormatter{ + issue: issue, + } + + assignees := ipf.getAssigneeList() + labels := ipf.getLabelList() + projects := ipf.getProjectList() + + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(out, "title:\t%s\n", issue.Title) + fmt.Fprintf(out, "state:\t%s\n", issue.State) + fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labels) + fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) + fmt.Fprintf(out, "assignees:\t%s\n", assignees) + fmt.Fprintf(out, "projects:\t%s\n", projects) + var milestoneTitle string + if issue.Milestone != nil { + milestoneTitle = issue.Milestone.Title + } + fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) + fmt.Fprintf(out, "number:\t%d\n", issue.Number) + fmt.Fprintln(out, "--") + fmt.Fprintln(out, issue.Body) + return nil +} + +func humanIssuePreview(ipf *IssuePrintFormatter, isCommentsPreview bool) error { + + // header (Title and State) + ipf.header() + // Reactions + ipf.reactions() + // Metadata + ipf.assigneeList() + ipf.labelList() + ipf.projectList() + ipf.milestone() + + // Body + err := ipf.body() + if err != nil { + return err + } + + // Comments + err = ipf.comments(isCommentsPreview) + if err != nil { + return err + } + + // Footer + ipf.footer() + + return nil +} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 6847d9178c6..4ce44d183fc 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,12 +2,15 @@ package view import ( "bytes" + "encoding/json" "fmt" "io" "net/http" + "os" "testing" "time" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -495,3 +498,114 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } + +// This is an Integration Test for the humanPreview +func Test_humanPreview(t *testing.T) { + tests := map[string]struct { + baseRepo ghrepo.Interface + issueFixture string + isCommentPreview bool + expectedOutput string + }{ + "projectcards included (v1 projects are supported)": { + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", + isCommentPreview: true, + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + "projectcards aren't included (v1 projects are supported)": { + baseRepo: ghrepo.New("OWNER", "REPO"), + issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", + isCommentPreview: true, + // This is super fragile but it's working for now + expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + issue := &api.Issue{} + + f, err := os.Open(tc.issueFixture) + if err != nil { + t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) + } + defer f.Close() + + dec := json.NewDecoder(f) + err = dec.Decode(&issue) + if err != nil { + t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) + } + + ipf := &IssuePrintFormatter{ + issue: issue, + colorScheme: ios.ColorScheme(), + IO: ios, + time: issue.CreatedAt.Add(24 * time.Hour), + baseRepo: tc.baseRepo, + } + + err = humanIssuePreview(ipf, tc.isCommentPreview) + if err != nil { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} + +func Test_rawPreview(t *testing.T) { + tests := map[string]struct { + Issue *api.Issue + issueCreatedAt string + expectedOutput string + }{ + "basic issue": { + Issue: &api.Issue{ + Title: "issueTitle", + State: "issueState", + Author: api.Author{ + Login: "authorLogin", + }, + Comments: api.Comments{ + TotalCount: 1, + }, + Milestone: &api.Milestone{ + Title: "milestoneTitle", + }, + Number: 123, + Body: "issueBody", + }, + issueCreatedAt: "2011-01-26T19:01:12Z", + expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\t\ncomments:\t1\nassignees:\t\nprojects:\t\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + createdAt, err := time.Parse(time.RFC3339, "2011-01-26T19:01:12Z") + if err != nil { + t.Errorf("Error parsing time: %v", err) + } + + tc.Issue.CreatedAt = createdAt + + err = rawIssuePreview(ios, tc.Issue) + if err != nil { + assert.NoError(t, err) + } + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} From 3567f25a934e03f123bd20d8cdf3eff0310161c1 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Mon, 26 Aug 2024 17:01:30 -0700 Subject: [PATCH 07/21] Refactor Issue and Label to handle their own formatted data This moves getAssigneeList, getProjectList, and getLabelList out of the IssuePrintFormatter and into either Issue or Label with a small amount of refactoring to separate functionality that was previously intertwined. - getAssigneeList -> GetAssigneeListString - getProjectList -> GetProjectListString - getLabelList -> SortAlphabeticallyIgnoreCase (Labels), getColorizedLabelsList (IssuePrintFormatter) GetAssigneeListString and GetProjectListString now have tests and can be called from any Issue getLabelList saw a few other changes and one design choice. Essentially, the old getLabelList function was doing two things: sorting the labels alphabetically, ignoring case, and colorizing the labels if a Color field was included in the Label. I've separated this functionality into two places: Labels now handle sorting of themselves with the SortAlphabeticallyIgnoreCase method on the Labels struct, and colorization is done by the IssuePrintFormatter. This ultimately led to me removing colorization from the rawIssuePreview labels, but can be added back in with a little thought and refactor if we decide colorization of the rawIssuePreview is desireable. --- api/queries_issue.go | 62 ++++++- api/queries_issue_test.go | 156 ++++++++++++++++++ .../{issuePrint.go => issuePrintFormatter.go} | 56 +------ pkg/cmd/issue/view/view.go | 14 +- 4 files changed, 223 insertions(+), 65 deletions(-) create mode 100644 api/queries_issue_test.go rename pkg/cmd/issue/view/{issuePrint.go => issuePrintFormatter.go} (67%) diff --git a/api/queries_issue.go b/api/queries_issue.go index 094b6b198c7..9b5c62717b3 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "fmt" + "slices" + "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" @@ -82,6 +84,12 @@ func (l Labels) Names() []string { return names } +func (l Labels) SortAlphabeticallyIgnoreCase() { + slices.SortStableFunc(l.Nodes, func(a, b IssueLabel) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) +} + type ProjectCards struct { Nodes []*ProjectInfo TotalCount int @@ -92,12 +100,16 @@ type ProjectItems struct { } type ProjectInfo struct { - Project struct { - Name string `json:"name"` - } `json:"project"` - Column struct { - Name string `json:"name"` - } `json:"column"` + Project ProjectV1ProjectName `json:"project"` + Column ProjectV1ProjectColumn `json:"column"` +} + +type ProjectV1ProjectName struct { + Name string `json:"name"` +} + +type ProjectV1ProjectColumn struct { + Name string `json:"name"` } type ProjectV2Item struct { @@ -333,3 +345,41 @@ func (i Issue) Identifier() string { func (i Issue) CurrentUserComments() []Comment { return i.Comments.CurrentUserComments() } + +func (i Issue) GetAssigneeListString() string { + if len(i.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(i.Assignees.Nodes)) + for _, assignee := range i.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if i.Assignees.TotalCount > len(i.Assignees.Nodes) { + list += ", …" + } + return list +} + +func (i Issue) GetProjectListString() string { + if len(i.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(i.ProjectCards.Nodes)) + for _, project := range i.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if i.ProjectCards.TotalCount > len(i.ProjectCards.Nodes) { + list += ", …" + } + return list +} diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go new file mode 100644 index 00000000000..fb7b9e8787c --- /dev/null +++ b/api/queries_issue_test.go @@ -0,0 +1,156 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetAssigneeListString(t *testing.T) { + tests := map[string]struct { + assignees []GitHubUser + expected string + }{ + "two assignees": { + assignees: []GitHubUser{ + {Login: "monalisa"}, + {Login: "hubot"}, + }, + expected: "monalisa, hubot", + }, + "no assignees": { + assignees: []GitHubUser{}, + expected: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + issue := &Issue{ + Assignees: Assignees{ + Nodes: tc.assignees, + }, + } + assert.Equal(t, tc.expected, issue.GetAssigneeListString()) + }) + } +} + +func TestGetProjectListString(t *testing.T) { + tests := map[string]struct { + projectCards ProjectCards + projectItems ProjectItems + expected string + }{ + "no projects": { + projectCards: ProjectCards{ + Nodes: []*ProjectInfo{}, + TotalCount: 0, + }, + projectItems: ProjectItems{ + Nodes: []*ProjectV2Item{}, + }, + expected: "", + }, + "two v1 projects and no v2 projects": { + projectCards: ProjectCards{ + Nodes: []*ProjectInfo{ + {Project: ProjectV1ProjectName{Name: "Project 1"}, Column: ProjectV1ProjectColumn{Name: "Column 1"}}, + {Project: ProjectV1ProjectName{Name: "Project 2"}, Column: ProjectV1ProjectColumn{Name: "Column 2"}}, + }, + TotalCount: 2, + }, + projectItems: ProjectItems{ + Nodes: []*ProjectV2Item{}, + }, + expected: "Project 1 (Column 1), Project 2 (Column 2)", + }, + "two v1 projects without columns and no v2 projects": { + projectCards: ProjectCards{ + Nodes: []*ProjectInfo{ + {Project: ProjectV1ProjectName{Name: "Project 1"}, Column: ProjectV1ProjectColumn{Name: ""}}, + {Project: ProjectV1ProjectName{Name: "Project 2"}, Column: ProjectV1ProjectColumn{Name: ""}}, + }, + TotalCount: 2, + }, + projectItems: ProjectItems{ + Nodes: []*ProjectV2Item{}, + }, + expected: "Project 1 (Awaiting triage), Project 2 (Awaiting triage)", + }, + "no v1 projects and 2 v2 projects": { + projectCards: ProjectCards{ + Nodes: []*ProjectInfo{}, + TotalCount: 0, + }, + projectItems: ProjectItems{ + Nodes: []*ProjectV2Item{}, + }, + expected: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + issue := &Issue{ + ProjectCards: tc.projectCards, + ProjectItems: tc.projectItems, + } + assert.Equal(t, tc.expected, issue.GetProjectListString()) + }) + } +} + +func Test_Labels_SortAlphabeticallyIgnoreCase(t *testing.T) { + tests := map[string]struct { + labels Labels + expected Labels + }{ + "no repeat labels": { + labels: Labels{ + Nodes: []IssueLabel{ + {Name: "c"}, + {Name: "B"}, + {Name: "a"}, + }, + }, + expected: Labels{ + Nodes: []IssueLabel{ + {Name: "a"}, + {Name: "B"}, + {Name: "c"}, + }, + }, + }, + "repeat labels case insensitive": { + labels: Labels{ + Nodes: []IssueLabel{ + {Name: "c"}, + {Name: "B"}, + {Name: "C"}, + }, + }, + expected: Labels{ + Nodes: []IssueLabel{ + {Name: "B"}, + {Name: "c"}, + {Name: "C"}, + }, + }, + }, + "no labels": { + labels: Labels{ + Nodes: []IssueLabel{}, + }, + expected: Labels{ + Nodes: []IssueLabel{}, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tc.labels.SortAlphabeticallyIgnoreCase() + assert.Equal(t, tc.expected, tc.labels) + }) + } +} diff --git a/pkg/cmd/issue/view/issuePrint.go b/pkg/cmd/issue/view/issuePrintFormatter.go similarity index 67% rename from pkg/cmd/issue/view/issuePrint.go rename to pkg/cmd/issue/view/issuePrintFormatter.go index 9a20f3c1740..0dfb1070e91 100644 --- a/pkg/cmd/issue/view/issuePrint.go +++ b/pkg/cmd/issue/view/issuePrintFormatter.go @@ -2,7 +2,6 @@ package view import ( "fmt" - "sort" "strings" "time" @@ -50,74 +49,27 @@ func (i *IssuePrintFormatter) reactions() { } func (i *IssuePrintFormatter) assigneeList() { - if assignees := i.getAssigneeList(); assignees != "" { + if assignees := i.issue.GetAssigneeListString(); assignees != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) fmt.Fprintln(i.IO.Out, assignees) } } -func (i *IssuePrintFormatter) getAssigneeList() string { - if len(i.issue.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(i.issue.Assignees.Nodes)) - for _, assignee := range i.issue.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if i.issue.Assignees.TotalCount > len(i.issue.Assignees.Nodes) { - list += ", …" - } - return list -} - func (i *IssuePrintFormatter) labelList() { - if labels := i.getLabelList(); labels != "" { + if labels := i.getColorizedLabelsList(); labels != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) fmt.Fprintln(i.IO.Out, labels) } } func (i *IssuePrintFormatter) projectList() { - if projects := i.getProjectList(); projects != "" { + if projects := i.issue.GetProjectListString(); projects != "" { fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) fmt.Fprintln(i.IO.Out, projects) } } -func (i *IssuePrintFormatter) getProjectList() string { - if len(i.issue.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(i.issue.ProjectCards.Nodes)) - for _, project := range i.issue.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if i.issue.ProjectCards.TotalCount > len(i.issue.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func (i *IssuePrintFormatter) getLabelList() string { - if len(i.issue.Labels.Nodes) == 0 { - return "" - } - - // ignore case sort - sort.SliceStable(i.issue.Labels.Nodes, func(j, k int) bool { - return strings.ToLower(i.issue.Labels.Nodes[j].Name) < strings.ToLower(i.issue.Labels.Nodes[k].Name) - }) - +func (i *IssuePrintFormatter) getColorizedLabelsList() string { labelNames := make([]string, len(i.issue.Labels.Nodes)) for j, label := range i.issue.Labels.Nodes { if i.colorScheme == nil { diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index a6b1cd75f35..2398c92701e 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/MakeNowJust/heredoc" @@ -129,6 +130,8 @@ func viewRun(opts *ViewOptions) error { return opts.Exporter.Write(opts.IO, issue) } + issue.Labels.SortAlphabeticallyIgnoreCase() + ipf := &IssuePrintFormatter{ issue: issue, colorScheme: opts.IO.ColorScheme(), @@ -172,13 +175,10 @@ func rawIssuePreview(IO *iostreams.IOStreams, issue *api.Issue) error { out := IO.Out - ipf := &IssuePrintFormatter{ - issue: issue, - } - - assignees := ipf.getAssigneeList() - labels := ipf.getLabelList() - projects := ipf.getProjectList() + assignees := issue.GetAssigneeListString() + // Labels no longer have color in the raw issue preview + labels := strings.Join(issue.Labels.Names(), ", ") + projects := issue.GetProjectListString() // Print empty strings for empty values so the number of metadata lines is consistent when // processing many issues with head and grep. From 6d4ce4713e9e96cd0cf3d41eb418556cd7e92f8d Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Tue, 27 Aug 2024 15:42:33 -0700 Subject: [PATCH 08/21] Add tests for the IssuePrintFormatter This adds tests to cover the methods inside the IssuePrintFormatter. Note: there is some over-testing in a few of these cases because of how both Issue and Label are defined. We would need to refactor them to use interfaces for mocking out this functionality if these tests are to be pure unit tests. As of now, that seems like a bit too much work for the scope of this commit. --- pkg/cmd/issue/view/issuePrintFormatter.go | 10 + .../issue/view/issuePrintFormatter_test.go | 619 ++++++++++++++++++ pkg/cmd/issue/view/view.go | 8 +- pkg/cmd/issue/view/view_test.go | 8 +- 4 files changed, 631 insertions(+), 14 deletions(-) create mode 100644 pkg/cmd/issue/view/issuePrintFormatter_test.go diff --git a/pkg/cmd/issue/view/issuePrintFormatter.go b/pkg/cmd/issue/view/issuePrintFormatter.go index 0dfb1070e91..e26f849eec8 100644 --- a/pkg/cmd/issue/view/issuePrintFormatter.go +++ b/pkg/cmd/issue/view/issuePrintFormatter.go @@ -21,6 +21,16 @@ type IssuePrintFormatter struct { baseRepo ghrepo.Interface } +func NewIssuePrintFormatter(issue *api.Issue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { + return &IssuePrintFormatter{ + issue: issue, + colorScheme: IO.ColorScheme(), + IO: IO, + time: timeNow, + baseRepo: baseRepo, + } +} + func (i *IssuePrintFormatter) header() { fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) fmt.Fprintf(i.IO.Out, diff --git a/pkg/cmd/issue/view/issuePrintFormatter_test.go b/pkg/cmd/issue/view/issuePrintFormatter_test.go new file mode 100644 index 00000000000..ddfab606eb4 --- /dev/null +++ b/pkg/cmd/issue/view/issuePrintFormatter_test.go @@ -0,0 +1,619 @@ +package view + +import ( + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/magiconair/properties/assert" +) + +// This does not test color, just output +func Test_header(t *testing.T) { + tests := map[string]struct { + title string + number int + state string + createdAt string + author string + baseRepo ghrepo.Interface + expected string + }{ + "simple open issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "OPEN", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nOpen • monalisa opened about 1 day ago • 1 comment\n", + }, + "simple closed issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "CLOSED", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nClosed • monalisa opened about 1 day ago • 1 comment\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + createdAtTime, err := time.Parse(time.RFC3339, tc.createdAt) + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + Title: tc.title, + Number: tc.number, + Comments: api.Comments{ + TotalCount: 1, + }, + State: tc.state, + CreatedAt: createdAtTime, + Author: api.Author{ + Login: tc.author, + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, createdAtTime.AddDate(0, 0, 1), tc.baseRepo) + ipf.header() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_reactions(t *testing.T) { + tests := map[string]struct { + reactions api.ReactionGroups + expected string + }{ + "no reactions": { + reactions: api.ReactionGroups{}, + expected: "", + }, + "single thumbs up reaction": { + reactions: api.ReactionGroups{ + api.ReactionGroup{ + Content: "THUMBS_UP", + Users: api.ReactionGroupUsers{ + TotalCount: 1, + }, + }, + }, + expected: "1 \U0001f44d\n", + }, + "all reactions": { + reactions: api.ReactionGroups{ + api.ReactionGroup{ + Content: "THUMBS_UP", + Users: api.ReactionGroupUsers{ + TotalCount: 1, + }, + }, + api.ReactionGroup{ + Content: "THUMBS_DOWN", + Users: api.ReactionGroupUsers{ + TotalCount: 2, + }, + }, + api.ReactionGroup{ + Content: "LAUGH", + Users: api.ReactionGroupUsers{ + TotalCount: 3, + }, + }, + api.ReactionGroup{ + Content: "HOORAY", + Users: api.ReactionGroupUsers{ + TotalCount: 4, + }, + }, + api.ReactionGroup{ + Content: "CONFUSED", + Users: api.ReactionGroupUsers{ + TotalCount: 5, + }, + }, + api.ReactionGroup{ + Content: "HEART", + Users: api.ReactionGroupUsers{ + TotalCount: 6, + }, + }, + api.ReactionGroup{ + Content: "ROCKET", + Users: api.ReactionGroupUsers{ + TotalCount: 7, + }, + }, + api.ReactionGroup{ + Content: "EYES", + Users: api.ReactionGroupUsers{ + TotalCount: 8, + }, + }, + }, + expected: "1 \U0001f44d • 2 \U0001f44e • 3 \U0001f604 • 4 \U0001f389 • 5 \U0001f615 • 6 \u2764\ufe0f • 7 \U0001f680 • 8 \U0001f440\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + ReactionGroups: tc.reactions, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.reactions() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +// Note: this is currently over-testing. We should be able to mock the +// return from GetAssigneeListString (which has its own tests), but I think +// that requires a larger refactor of the Issue code to leverage interfaces +// for mocking. +func Test_assigneeList(t *testing.T) { + tests := map[string]struct { + assignees []string + expected string + }{ + "no assignees": { + assignees: []string{}, + expected: "", + }, + "single assignee": { + assignees: []string{"monalisa"}, + expected: "Assignees: monalisa\n", + }, + "multiple assignees": { + assignees: []string{"monalisa", "octocat"}, + expected: "Assignees: monalisa, octocat\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + totalCount := len(tc.assignees) + assigneeNodes := make([]api.GitHubUser, totalCount) + + for i, assignee := range tc.assignees { + assigneeNodes[i] = api.GitHubUser{ + Login: assignee, + } + } + + issue := &api.Issue{ + Assignees: api.Assignees{ + Nodes: assigneeNodes, + TotalCount: totalCount, + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.assigneeList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_labelList(t *testing.T) { + tests := map[string]struct { + labelNodes []api.IssueLabel + expected string + }{ + "no labels": { + labelNodes: []api.IssueLabel{}, + expected: "", + }, + "single label": { + labelNodes: []api.IssueLabel{ + { + Name: "bug", + Color: "ff0000", + }, + }, + expected: "Labels: bug\n", + }, + "multiple labels": { + labelNodes: []api.IssueLabel{ + { + Name: "bug", + Color: "ff0000", + }, + { + Name: "enhancement", + Color: "00ff00", + }, + }, + expected: "Labels: bug, enhancement\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + Labels: api.Labels{ + Nodes: tc.labelNodes, + TotalCount: len(tc.labelNodes), + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.labelList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +// Note: this is currently over-testing. We should be able to mock the +// return from GetProjectListString (which has its own tests), but I think +// that requires a larger refactor of the Issue code to leverage interfaces +// for mocking. +func Test_projectList(t *testing.T) { + tests := map[string]struct { + projectCardsNodes []*api.ProjectInfo + projectItemsNodes []*api.ProjectV2Item + expected string + }{ + "no projects": { + projectCardsNodes: []*api.ProjectInfo{}, + projectItemsNodes: []*api.ProjectV2Item{}, + expected: "", + }, + "some projects": { + projectCardsNodes: []*api.ProjectInfo{ + { + Project: api.ProjectV1ProjectName{ + Name: "ProjectV1 1", + }, + Column: api.ProjectV1ProjectColumn{ + Name: "Column 1", + }, + }, + { + Project: api.ProjectV1ProjectName{ + Name: "ProjectV1 2", + }, + Column: api.ProjectV1ProjectColumn{ + Name: "", + }, + }, + }, + projectItemsNodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + }, + expected: "Projects: ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + ProjectCards: api.ProjectCards{ + Nodes: tc.projectCardsNodes, + TotalCount: len(tc.projectCardsNodes), + }, + ProjectItems: api.ProjectItems{ + Nodes: tc.projectItemsNodes, + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.projectList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_getColorizedLabelsList(t *testing.T) { + tests := map[string]struct { + labelNodes []api.IssueLabel + isColorSchemeEnabled bool + expected string + }{ + "no labels": { + labelNodes: []api.IssueLabel{}, + expected: "", + }, + "single label no colorScheme": { + labelNodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + isColorSchemeEnabled: false, + expected: "bug", + }, + "single label with colorScheme": { + labelNodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + isColorSchemeEnabled: true, + expected: "\033[38;2;252;3;3mbug\033[0m", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetColorEnabled(tc.isColorSchemeEnabled) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + Labels: api.Labels{ + Nodes: tc.labelNodes, + TotalCount: len(tc.labelNodes), + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + assert.Equal(t, ipf.getColorizedLabelsList(), tc.expected) + }) + } +} + +func Test_milestone(t *testing.T) { + tests := map[string]struct { + milestone string + expected string + }{ + "no milestone": { + milestone: "", + expected: "", + }, + "milestone": { + milestone: "milestoneTitle", + expected: "Milestone: milestoneTitle\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{} + + if tc.milestone != "" { + issue.Milestone = &api.Milestone{ + Title: tc.milestone, + } + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.milestone() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +// No idea why this isn't passing... +func Test_body(t *testing.T) { + tests := map[string]struct { + body string + expected string + }{ + "no body": { + body: "", + expected: "No description provided", + }, + "with body": { + body: "This is a body", + expected: "This is a body", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + Body: tc.body, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + err = ipf.body() + if err != nil { + t.Fatal(err) + } + // This is getting around whitespace issues I was having with assert.Equal + assert.Matches(t, stdout.String(), tc.expected) + }) + } +} + +func Test_comments(t *testing.T) { + test := map[string]struct { + commentNodes []api.Comment + expected string + isPreview bool + }{ + "no comments": { + commentNodes: []api.Comment{}, + expected: "", + isPreview: false, + }, + "comments": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", + isPreview: false, + }, + "preview": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", + isPreview: true, + }, + } + for name, tc := range test { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + for i := range tc.commentNodes { + // subtract a day + tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) + } + + issue := &api.Issue{ + Comments: api.Comments{ + Nodes: tc.commentNodes, + TotalCount: len(tc.commentNodes), + }, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + err = ipf.comments(tc.isPreview) + if err != nil { + t.Fatal(err) + } + + // I just can't get the strings to match... + // assert.Equal(t, stdout.Strings(), tc.expected) + }) + } +} + +func Test_footer(t *testing.T) { + tests := map[string]struct { + url string + expected string + }{ + "no url": { + url: "", + expected: "View this issue on GitHub: \n", + }, + "with url": { + url: "github.com/OWNER/REPO/issues/123", + expected: "View this issue on GitHub: github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + issue := &api.Issue{ + URL: tc.url, + } + + ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.footer() + if err != nil { + t.Fatal(err) + } + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 2398c92701e..ea3acfbee4b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -132,13 +132,7 @@ func viewRun(opts *ViewOptions) error { issue.Labels.SortAlphabeticallyIgnoreCase() - ipf := &IssuePrintFormatter{ - issue: issue, - colorScheme: opts.IO.ColorScheme(), - IO: opts.IO, - time: opts.Now(), - baseRepo: baseRepo, - } + ipf := NewIssuePrintFormatter(issue, opts.IO, opts.Now(), baseRepo) if opts.IO.IsStdoutTTY() { isCommentsPreview := !opts.Comments diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 4ce44d183fc..561b7f99157 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -543,13 +543,7 @@ func Test_humanPreview(t *testing.T) { t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) } - ipf := &IssuePrintFormatter{ - issue: issue, - colorScheme: ios.ColorScheme(), - IO: ios, - time: issue.CreatedAt.Add(24 * time.Hour), - baseRepo: tc.baseRepo, - } + ipf := NewIssuePrintFormatter(issue, ios, issue.CreatedAt.Add(24*time.Hour), tc.baseRepo) err = humanIssuePreview(ipf, tc.isCommentPreview) if err != nil { From 1844db8e973c1b7fbcad136014124f0db742243f Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 28 Aug 2024 15:48:37 -0700 Subject: [PATCH 09/21] Refactor IssuePrintFormatter with new presentationIssue struct Based on feedback from @williammartin to my question about using interfaces for mocking in testing, I've created a new presentationIssue struct that, when created, handles all the string formatting of an issue that the IssuePrintFormatter needs. That removed the need for mocking functions, as the IssuePrintFormatter now just uses any defined presentationIssue struct. There's still a few questions I have about this that I expect to discuss with @williammartin and address before this is completed --- api/queries_issue.go | 38 - api/queries_issue_test.go | 95 --- pkg/cmd/issue/shared/display.go | 2 +- pkg/cmd/issue/view/issuePrintFormatter.go | 132 ---- .../issue/view/issuePrintFormatter_test.go | 619 --------------- pkg/cmd/issue/view/issue_print_formatter.go | 271 +++++++ .../issue/view/issue_print_formatter_test.go | 709 ++++++++++++++++++ pkg/cmd/issue/view/view.go | 71 +- pkg/cmd/issue/view/view_test.go | 108 --- pkg/cmd/pr/shared/display.go | 6 +- 10 files changed, 992 insertions(+), 1059 deletions(-) delete mode 100644 pkg/cmd/issue/view/issuePrintFormatter.go delete mode 100644 pkg/cmd/issue/view/issuePrintFormatter_test.go create mode 100644 pkg/cmd/issue/view/issue_print_formatter.go create mode 100644 pkg/cmd/issue/view/issue_print_formatter_test.go diff --git a/api/queries_issue.go b/api/queries_issue.go index 9b5c62717b3..eac8f8af953 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -345,41 +345,3 @@ func (i Issue) Identifier() string { func (i Issue) CurrentUserComments() []Comment { return i.Comments.CurrentUserComments() } - -func (i Issue) GetAssigneeListString() string { - if len(i.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(i.Assignees.Nodes)) - for _, assignee := range i.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if i.Assignees.TotalCount > len(i.Assignees.Nodes) { - list += ", …" - } - return list -} - -func (i Issue) GetProjectListString() string { - if len(i.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(i.ProjectCards.Nodes)) - for _, project := range i.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if i.ProjectCards.TotalCount > len(i.ProjectCards.Nodes) { - list += ", …" - } - return list -} diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go index fb7b9e8787c..0221fb9adbd 100644 --- a/api/queries_issue_test.go +++ b/api/queries_issue_test.go @@ -6,101 +6,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetAssigneeListString(t *testing.T) { - tests := map[string]struct { - assignees []GitHubUser - expected string - }{ - "two assignees": { - assignees: []GitHubUser{ - {Login: "monalisa"}, - {Login: "hubot"}, - }, - expected: "monalisa, hubot", - }, - "no assignees": { - assignees: []GitHubUser{}, - expected: "", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - issue := &Issue{ - Assignees: Assignees{ - Nodes: tc.assignees, - }, - } - assert.Equal(t, tc.expected, issue.GetAssigneeListString()) - }) - } -} - -func TestGetProjectListString(t *testing.T) { - tests := map[string]struct { - projectCards ProjectCards - projectItems ProjectItems - expected string - }{ - "no projects": { - projectCards: ProjectCards{ - Nodes: []*ProjectInfo{}, - TotalCount: 0, - }, - projectItems: ProjectItems{ - Nodes: []*ProjectV2Item{}, - }, - expected: "", - }, - "two v1 projects and no v2 projects": { - projectCards: ProjectCards{ - Nodes: []*ProjectInfo{ - {Project: ProjectV1ProjectName{Name: "Project 1"}, Column: ProjectV1ProjectColumn{Name: "Column 1"}}, - {Project: ProjectV1ProjectName{Name: "Project 2"}, Column: ProjectV1ProjectColumn{Name: "Column 2"}}, - }, - TotalCount: 2, - }, - projectItems: ProjectItems{ - Nodes: []*ProjectV2Item{}, - }, - expected: "Project 1 (Column 1), Project 2 (Column 2)", - }, - "two v1 projects without columns and no v2 projects": { - projectCards: ProjectCards{ - Nodes: []*ProjectInfo{ - {Project: ProjectV1ProjectName{Name: "Project 1"}, Column: ProjectV1ProjectColumn{Name: ""}}, - {Project: ProjectV1ProjectName{Name: "Project 2"}, Column: ProjectV1ProjectColumn{Name: ""}}, - }, - TotalCount: 2, - }, - projectItems: ProjectItems{ - Nodes: []*ProjectV2Item{}, - }, - expected: "Project 1 (Awaiting triage), Project 2 (Awaiting triage)", - }, - "no v1 projects and 2 v2 projects": { - projectCards: ProjectCards{ - Nodes: []*ProjectInfo{}, - TotalCount: 0, - }, - projectItems: ProjectItems{ - Nodes: []*ProjectV2Item{}, - }, - expected: "", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - issue := &Issue{ - ProjectCards: tc.projectCards, - ProjectItems: tc.projectItems, - } - assert.Equal(t, tc.expected, issue.GetProjectListString()) - }) - } -} - func Test_Labels_SortAlphabeticallyIgnoreCase(t *testing.T) { tests := map[string]struct { labels Labels diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 0c56ffd2cae..7d8eea42454 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -32,7 +32,7 @@ func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCou issueNum = "#" + issueNum } issueNum = prefix + issueNum - table.AddField(issueNum, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForIssueState(issue)))) + table.AddField(issueNum, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForIssueState(issue.State, issue.StateReason)))) if !isTTY { table.AddField(issue.State) } diff --git a/pkg/cmd/issue/view/issuePrintFormatter.go b/pkg/cmd/issue/view/issuePrintFormatter.go deleted file mode 100644 index e26f849eec8..00000000000 --- a/pkg/cmd/issue/view/issuePrintFormatter.go +++ /dev/null @@ -1,132 +0,0 @@ -package view - -import ( - "fmt" - "strings" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/text" - prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" -) - -type IssuePrintFormatter struct { - issue *api.Issue - colorScheme *iostreams.ColorScheme - IO *iostreams.IOStreams - time time.Time - baseRepo ghrepo.Interface -} - -func NewIssuePrintFormatter(issue *api.Issue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { - return &IssuePrintFormatter{ - issue: issue, - colorScheme: IO.ColorScheme(), - IO: IO, - time: timeNow, - baseRepo: baseRepo, - } -} - -func (i *IssuePrintFormatter) header() { - fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.issue.Title), ghrepo.FullName(i.baseRepo), i.issue.Number) - fmt.Fprintf(i.IO.Out, - "%s • %s opened %s • %s\n", - i.issueStateTitleWithColor(), - i.issue.Author.Login, - text.FuzzyAgo(i.time, i.issue.CreatedAt), - text.Pluralize(i.issue.Comments.TotalCount, "comment"), - ) -} - -func (i *IssuePrintFormatter) issueStateTitleWithColor() string { - colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(*i.issue)) - state := "Open" - if i.issue.State == "CLOSED" { - state = "Closed" - } - return colorFunc(state) -} - -func (i *IssuePrintFormatter) reactions() { - if reactions := prShared.ReactionGroupList(i.issue.ReactionGroups); reactions != "" { - fmt.Fprint(i.IO.Out, reactions) - fmt.Fprintln(i.IO.Out) - } -} - -func (i *IssuePrintFormatter) assigneeList() { - if assignees := i.issue.GetAssigneeListString(); assignees != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) - fmt.Fprintln(i.IO.Out, assignees) - } -} - -func (i *IssuePrintFormatter) labelList() { - if labels := i.getColorizedLabelsList(); labels != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) - fmt.Fprintln(i.IO.Out, labels) - } -} - -func (i *IssuePrintFormatter) projectList() { - if projects := i.issue.GetProjectListString(); projects != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) - fmt.Fprintln(i.IO.Out, projects) - } -} - -func (i *IssuePrintFormatter) getColorizedLabelsList() string { - labelNames := make([]string, len(i.issue.Labels.Nodes)) - for j, label := range i.issue.Labels.Nodes { - if i.colorScheme == nil { - labelNames[j] = label.Name - } else { - labelNames[j] = i.colorScheme.HexToRGB(label.Color, label.Name) - } - } - - return strings.Join(labelNames, ", ") -} - -func (i *IssuePrintFormatter) milestone() { - if i.issue.Milestone != nil { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) - fmt.Fprintln(i.IO.Out, i.issue.Milestone.Title) - } -} - -func (i *IssuePrintFormatter) body() error { - var md string - var err error - if i.issue.Body == "" { - md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) - } else { - md, err = markdown.Render(i.issue.Body, - markdown.WithTheme(i.IO.TerminalTheme()), - markdown.WithWrap(i.IO.TerminalWidth())) - if err != nil { - return err - } - } - fmt.Fprintf(i.IO.Out, "\n%s\n", md) - return nil -} - -func (i *IssuePrintFormatter) comments(isPreview bool) error { - if i.issue.Comments.TotalCount > 0 { - comments, err := prShared.CommentList(i.IO, i.issue.Comments, api.PullRequestReviews{}, isPreview) - if err != nil { - return err - } - fmt.Fprint(i.IO.Out, comments) - } - return nil -} - -func (i *IssuePrintFormatter) footer() { - fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.issue.URL) -} diff --git a/pkg/cmd/issue/view/issuePrintFormatter_test.go b/pkg/cmd/issue/view/issuePrintFormatter_test.go deleted file mode 100644 index ddfab606eb4..00000000000 --- a/pkg/cmd/issue/view/issuePrintFormatter_test.go +++ /dev/null @@ -1,619 +0,0 @@ -package view - -import ( - "testing" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/magiconair/properties/assert" -) - -// This does not test color, just output -func Test_header(t *testing.T) { - tests := map[string]struct { - title string - number int - state string - createdAt string - author string - baseRepo ghrepo.Interface - expected string - }{ - "simple open issue": { - title: "Simple Issue Test", - baseRepo: ghrepo.New("OWNER", "REPO"), - number: 123, - state: "OPEN", - author: "monalisa", - createdAt: "2022-01-01T00:00:00Z", - expected: "Simple Issue Test OWNER/REPO#123\nOpen • monalisa opened about 1 day ago • 1 comment\n", - }, - "simple closed issue": { - title: "Simple Issue Test", - baseRepo: ghrepo.New("OWNER", "REPO"), - number: 123, - state: "CLOSED", - author: "monalisa", - createdAt: "2022-01-01T00:00:00Z", - expected: "Simple Issue Test OWNER/REPO#123\nClosed • monalisa opened about 1 day ago • 1 comment\n", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - createdAtTime, err := time.Parse(time.RFC3339, tc.createdAt) - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - Title: tc.title, - Number: tc.number, - Comments: api.Comments{ - TotalCount: 1, - }, - State: tc.state, - CreatedAt: createdAtTime, - Author: api.Author{ - Login: tc.author, - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, createdAtTime.AddDate(0, 0, 1), tc.baseRepo) - ipf.header() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_reactions(t *testing.T) { - tests := map[string]struct { - reactions api.ReactionGroups - expected string - }{ - "no reactions": { - reactions: api.ReactionGroups{}, - expected: "", - }, - "single thumbs up reaction": { - reactions: api.ReactionGroups{ - api.ReactionGroup{ - Content: "THUMBS_UP", - Users: api.ReactionGroupUsers{ - TotalCount: 1, - }, - }, - }, - expected: "1 \U0001f44d\n", - }, - "all reactions": { - reactions: api.ReactionGroups{ - api.ReactionGroup{ - Content: "THUMBS_UP", - Users: api.ReactionGroupUsers{ - TotalCount: 1, - }, - }, - api.ReactionGroup{ - Content: "THUMBS_DOWN", - Users: api.ReactionGroupUsers{ - TotalCount: 2, - }, - }, - api.ReactionGroup{ - Content: "LAUGH", - Users: api.ReactionGroupUsers{ - TotalCount: 3, - }, - }, - api.ReactionGroup{ - Content: "HOORAY", - Users: api.ReactionGroupUsers{ - TotalCount: 4, - }, - }, - api.ReactionGroup{ - Content: "CONFUSED", - Users: api.ReactionGroupUsers{ - TotalCount: 5, - }, - }, - api.ReactionGroup{ - Content: "HEART", - Users: api.ReactionGroupUsers{ - TotalCount: 6, - }, - }, - api.ReactionGroup{ - Content: "ROCKET", - Users: api.ReactionGroupUsers{ - TotalCount: 7, - }, - }, - api.ReactionGroup{ - Content: "EYES", - Users: api.ReactionGroupUsers{ - TotalCount: 8, - }, - }, - }, - expected: "1 \U0001f44d • 2 \U0001f44e • 3 \U0001f604 • 4 \U0001f389 • 5 \U0001f615 • 6 \u2764\ufe0f • 7 \U0001f680 • 8 \U0001f440\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - ReactionGroups: tc.reactions, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.reactions() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -// Note: this is currently over-testing. We should be able to mock the -// return from GetAssigneeListString (which has its own tests), but I think -// that requires a larger refactor of the Issue code to leverage interfaces -// for mocking. -func Test_assigneeList(t *testing.T) { - tests := map[string]struct { - assignees []string - expected string - }{ - "no assignees": { - assignees: []string{}, - expected: "", - }, - "single assignee": { - assignees: []string{"monalisa"}, - expected: "Assignees: monalisa\n", - }, - "multiple assignees": { - assignees: []string{"monalisa", "octocat"}, - expected: "Assignees: monalisa, octocat\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - totalCount := len(tc.assignees) - assigneeNodes := make([]api.GitHubUser, totalCount) - - for i, assignee := range tc.assignees { - assigneeNodes[i] = api.GitHubUser{ - Login: assignee, - } - } - - issue := &api.Issue{ - Assignees: api.Assignees{ - Nodes: assigneeNodes, - TotalCount: totalCount, - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.assigneeList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_labelList(t *testing.T) { - tests := map[string]struct { - labelNodes []api.IssueLabel - expected string - }{ - "no labels": { - labelNodes: []api.IssueLabel{}, - expected: "", - }, - "single label": { - labelNodes: []api.IssueLabel{ - { - Name: "bug", - Color: "ff0000", - }, - }, - expected: "Labels: bug\n", - }, - "multiple labels": { - labelNodes: []api.IssueLabel{ - { - Name: "bug", - Color: "ff0000", - }, - { - Name: "enhancement", - Color: "00ff00", - }, - }, - expected: "Labels: bug, enhancement\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - Labels: api.Labels{ - Nodes: tc.labelNodes, - TotalCount: len(tc.labelNodes), - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.labelList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -// Note: this is currently over-testing. We should be able to mock the -// return from GetProjectListString (which has its own tests), but I think -// that requires a larger refactor of the Issue code to leverage interfaces -// for mocking. -func Test_projectList(t *testing.T) { - tests := map[string]struct { - projectCardsNodes []*api.ProjectInfo - projectItemsNodes []*api.ProjectV2Item - expected string - }{ - "no projects": { - projectCardsNodes: []*api.ProjectInfo{}, - projectItemsNodes: []*api.ProjectV2Item{}, - expected: "", - }, - "some projects": { - projectCardsNodes: []*api.ProjectInfo{ - { - Project: api.ProjectV1ProjectName{ - Name: "ProjectV1 1", - }, - Column: api.ProjectV1ProjectColumn{ - Name: "Column 1", - }, - }, - { - Project: api.ProjectV1ProjectName{ - Name: "ProjectV1 2", - }, - Column: api.ProjectV1ProjectColumn{ - Name: "", - }, - }, - }, - projectItemsNodes: []*api.ProjectV2Item{ - { - ID: "projectItemID", - Project: api.ProjectV2ItemProject{ - ID: "projectID", - Title: "V2 Project", - }, - Status: api.ProjectV2ItemStatus{ - OptionID: "statusID", - Name: "STATUS", - }, - }, - }, - expected: "Projects: ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - ProjectCards: api.ProjectCards{ - Nodes: tc.projectCardsNodes, - TotalCount: len(tc.projectCardsNodes), - }, - ProjectItems: api.ProjectItems{ - Nodes: tc.projectItemsNodes, - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.projectList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_getColorizedLabelsList(t *testing.T) { - tests := map[string]struct { - labelNodes []api.IssueLabel - isColorSchemeEnabled bool - expected string - }{ - "no labels": { - labelNodes: []api.IssueLabel{}, - expected: "", - }, - "single label no colorScheme": { - labelNodes: []api.IssueLabel{ - { - Name: "bug", - Color: "fc0303", - }, - }, - isColorSchemeEnabled: false, - expected: "bug", - }, - "single label with colorScheme": { - labelNodes: []api.IssueLabel{ - { - Name: "bug", - Color: "fc0303", - }, - }, - isColorSchemeEnabled: true, - expected: "\033[38;2;252;3;3mbug\033[0m", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - ios.SetColorEnabled(tc.isColorSchemeEnabled) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - Labels: api.Labels{ - Nodes: tc.labelNodes, - TotalCount: len(tc.labelNodes), - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - assert.Equal(t, ipf.getColorizedLabelsList(), tc.expected) - }) - } -} - -func Test_milestone(t *testing.T) { - tests := map[string]struct { - milestone string - expected string - }{ - "no milestone": { - milestone: "", - expected: "", - }, - "milestone": { - milestone: "milestoneTitle", - expected: "Milestone: milestoneTitle\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{} - - if tc.milestone != "" { - issue.Milestone = &api.Milestone{ - Title: tc.milestone, - } - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.milestone() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -// No idea why this isn't passing... -func Test_body(t *testing.T) { - tests := map[string]struct { - body string - expected string - }{ - "no body": { - body: "", - expected: "No description provided", - }, - "with body": { - body: "This is a body", - expected: "This is a body", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - Body: tc.body, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - err = ipf.body() - if err != nil { - t.Fatal(err) - } - // This is getting around whitespace issues I was having with assert.Equal - assert.Matches(t, stdout.String(), tc.expected) - }) - } -} - -func Test_comments(t *testing.T) { - test := map[string]struct { - commentNodes []api.Comment - expected string - isPreview bool - }{ - "no comments": { - commentNodes: []api.Comment{}, - expected: "", - isPreview: false, - }, - "comments": { - commentNodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", - isPreview: false, - }, - "preview": { - commentNodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", - isPreview: true, - }, - } - for name, tc := range test { - t.Run(name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - for i := range tc.commentNodes { - // subtract a day - tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) - } - - issue := &api.Issue{ - Comments: api.Comments{ - Nodes: tc.commentNodes, - TotalCount: len(tc.commentNodes), - }, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - err = ipf.comments(tc.isPreview) - if err != nil { - t.Fatal(err) - } - - // I just can't get the strings to match... - // assert.Equal(t, stdout.Strings(), tc.expected) - }) - } -} - -func Test_footer(t *testing.T) { - tests := map[string]struct { - url string - expected string - }{ - "no url": { - url: "", - expected: "View this issue on GitHub: \n", - }, - "with url": { - url: "github.com/OWNER/REPO/issues/123", - expected: "View this issue on GitHub: github.com/OWNER/REPO/issues/123\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - issue := &api.Issue{ - URL: tc.url, - } - - ipf := NewIssuePrintFormatter(issue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.footer() - if err != nil { - t.Fatal(err) - } - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} diff --git a/pkg/cmd/issue/view/issue_print_formatter.go b/pkg/cmd/issue/view/issue_print_formatter.go new file mode 100644 index 00000000000..731deb92e20 --- /dev/null +++ b/pkg/cmd/issue/view/issue_print_formatter.go @@ -0,0 +1,271 @@ +package view + +import ( + "fmt" + "strings" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" +) + +type IssuePrintFormatter struct { + presentationIssue *presentationIssue + colorScheme *iostreams.ColorScheme + IO *iostreams.IOStreams + time time.Time + baseRepo ghrepo.Interface +} + +type presentationIssue struct { + Title string + Number int + CreatedAt time.Time + Comments api.Comments + Author string + State string + StateReason string + Reactions string + AssigneesList string + LabelsList string + ProjectsList string + MilestoneTitle string + Body string + URL string +} + +func NewIssuePrintFormatter(presentationIssue *presentationIssue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { + return &IssuePrintFormatter{ + presentationIssue: presentationIssue, + colorScheme: IO.ColorScheme(), + IO: IO, + time: timeNow, + baseRepo: baseRepo, + } +} + +func apiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (*presentationIssue, error) { + presentationIssue := &presentationIssue{ + Title: issue.Title, + Number: issue.Number, + CreatedAt: issue.CreatedAt, + Comments: issue.Comments, + Author: issue.Author.Login, + State: issue.State, + StateReason: issue.StateReason, + Reactions: prShared.ReactionGroupList(issue.ReactionGroups), + AssigneesList: getAssigneeListString(issue.Assignees), + // It feels weird to add color here... + LabelsList: getColorizedLabelsList(issue.Labels, colorScheme), + ProjectsList: getProjectListString(issue.ProjectCards, issue.ProjectItems), + Body: issue.Body, + URL: issue.URL, + } + + if issue.Milestone != nil { + presentationIssue.MilestoneTitle = issue.Milestone.Title + } + + return presentationIssue, nil +} + +func getProjectListString(projectCards api.ProjectCards, projectItems api.ProjectItems) string { + if len(projectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(projectCards.Nodes)) + for _, project := range projectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if projectCards.TotalCount > len(projectCards.Nodes) { + list += ", …" + } + return list +} + +func getAssigneeListString(issueAssignees api.Assignees) string { + if len(issueAssignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(issueAssignees.Nodes)) + for _, assignee := range issueAssignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if issueAssignees.TotalCount > len(issueAssignees.Nodes) { + list += ", …" + } + return list +} + +func getColorizedLabelsList(issueLabels api.Labels, colorScheme *iostreams.ColorScheme) string { + labelNames := make([]string, len(issueLabels.Nodes)) + for j, label := range issueLabels.Nodes { + if colorScheme == nil { + labelNames[j] = label.Name + } else { + labelNames[j] = colorScheme.HexToRGB(label.Color, label.Name) + } + } + + return strings.Join(labelNames, ", ") +} + +func (ipf *IssuePrintFormatter) renderHumanIssuePreview(isCommentsPreview bool) error { + + // I think I'd like to make this easier to understand what the output should look like. + // That's probably doable by removing these helpers and just using a formatted string. + // I might experiment with that later. + + // header (Title and State) + ipf.header() + // Reactions + ipf.reactions() + // Metadata + ipf.assigneeList() + ipf.labelList() + ipf.projectList() + + ipf.milestone() + + // Body + err := ipf.body() + if err != nil { + return err + } + + // Comments + err = ipf.comments(isCommentsPreview) + if err != nil { + return err + } + + // Footer + ipf.footer() + + return nil +} + +func (ipf *IssuePrintFormatter) renderRawIssuePreview() error { + + out := ipf.IO.Out + pi := ipf.presentationIssue + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(out, "title:\t%s\n", pi.Title) + fmt.Fprintf(out, "state:\t%s\n", pi.State) + fmt.Fprintf(out, "author:\t%s\n", pi.Author) + fmt.Fprintf(out, "labels:\t%s\n", pi.LabelsList) + fmt.Fprintf(out, "comments:\t%d\n", pi.Comments.TotalCount) + fmt.Fprintf(out, "assignees:\t%s\n", pi.AssigneesList) + fmt.Fprintf(out, "projects:\t%s\n", pi.ProjectsList) + fmt.Fprintf(out, "milestone:\t%s\n", pi.MilestoneTitle) + fmt.Fprintf(out, "number:\t%d\n", pi.Number) + fmt.Fprintln(out, "--") + fmt.Fprintln(out, pi.Body) + return nil +} + +func (i *IssuePrintFormatter) header() { + fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.presentationIssue.Title), ghrepo.FullName(i.baseRepo), i.presentationIssue.Number) + fmt.Fprintf(i.IO.Out, + "%s • %s opened %s • %s\n", + i.issueStateTitleWithColor(), + i.presentationIssue.Author, + text.FuzzyAgo(i.time, i.presentationIssue.CreatedAt), + text.Pluralize(i.presentationIssue.Comments.TotalCount, "comment"), + ) +} + +func (i *IssuePrintFormatter) issueStateTitleWithColor() string { + colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(i.presentationIssue.State, i.presentationIssue.StateReason)) + state := "Open" + if i.presentationIssue.State == "CLOSED" { + state = "Closed" + } + return colorFunc(state) +} + +func (i *IssuePrintFormatter) reactions() { + if i.presentationIssue.Reactions != "" { + fmt.Fprint(i.IO.Out, i.presentationIssue.Reactions) + fmt.Fprintln(i.IO.Out) + } +} + +func (i *IssuePrintFormatter) assigneeList() { + assignees := i.presentationIssue.AssigneesList + if assignees != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) + fmt.Fprintln(i.IO.Out, assignees) + } +} + +func (i *IssuePrintFormatter) labelList() { + labels := i.presentationIssue.LabelsList + if labels != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) + fmt.Fprintln(i.IO.Out, labels) + } +} + +func (i *IssuePrintFormatter) projectList() { + projects := i.presentationIssue.ProjectsList + if projects != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) + fmt.Fprintln(i.IO.Out, projects) + } +} + +func (i *IssuePrintFormatter) milestone() { + if i.presentationIssue.MilestoneTitle != "" { + fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) + fmt.Fprintln(i.IO.Out, i.presentationIssue.MilestoneTitle) + } +} + +func (i *IssuePrintFormatter) body() error { + var md string + var err error + body := i.presentationIssue.Body + if body == "" { + md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) + } else { + md, err = markdown.Render(body, + markdown.WithTheme(i.IO.TerminalTheme()), + markdown.WithWrap(i.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(i.IO.Out, "\n%s\n", md) + return nil +} + +func (i *IssuePrintFormatter) comments(isPreview bool) error { + if i.presentationIssue.Comments.TotalCount > 0 { + comments, err := prShared.CommentList(i.IO, i.presentationIssue.Comments, api.PullRequestReviews{}, isPreview) + if err != nil { + return err + } + fmt.Fprint(i.IO.Out, comments) + } + return nil +} + +func (i *IssuePrintFormatter) footer() { + fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.presentationIssue.URL) +} diff --git a/pkg/cmd/issue/view/issue_print_formatter_test.go b/pkg/cmd/issue/view/issue_print_formatter_test.go new file mode 100644 index 00000000000..ebb7e171a83 --- /dev/null +++ b/pkg/cmd/issue/view/issue_print_formatter_test.go @@ -0,0 +1,709 @@ +package view + +import ( + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/magiconair/properties/assert" +) + +func Test_apiIssueToPresentationIssue(t *testing.T) { + createdAt, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + issue *api.Issue + expect *presentationIssue + }{ + "basic integration test": { + issue: &api.Issue{ + Title: "Title", + Number: 123, + Comments: api.Comments{ + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "comment body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + TotalCount: 1, + }, + State: "OPEN", + StateReason: "", + URL: "github.com/OWNER/REPO/issues/123", + Author: api.Author{ + Login: "octocat", + Name: "Octo Cat", + ID: "321", + }, + Assignees: api.Assignees{ + Nodes: []api.GitHubUser{ + { + Login: "octocat", + Name: "Octo Cat", + ID: "321", + }, + }, + TotalCount: 1, + }, + Labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + TotalCount: 1, + }, + ProjectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + { + Project: api.ProjectV1ProjectName{ + Name: "ProjectCardName", + }, + Column: api.ProjectV1ProjectColumn{ + Name: "ProjectCardColumn", + }, + }, + }, + TotalCount: 1, + }, + ProjectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + }, + }, + Milestone: &api.Milestone{ + Title: "MilestoneTitle", + }, + ReactionGroups: api.ReactionGroups{ + { + Content: "THUMBS_UP", + Users: api.ReactionGroupUsers{ + TotalCount: 1, + }, + }, + }, + CreatedAt: createdAt, + }, + expect: &presentationIssue{ + Title: "Title", + Number: 123, + CreatedAt: createdAt, + Comments: api.Comments{ + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "comment body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + TotalCount: 1, + }, + State: "OPEN", + StateReason: "", + Reactions: "1 \U0001f44d", + AssigneesList: "octocat", + LabelsList: "bug", + ProjectsList: "ProjectCardName (ProjectCardColumn), V2ProjectName", + MilestoneTitle: "MilestoneTitle", + Body: "", + URL: "github.com/OWNER/REPO/issues/123", + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + presentationIssue, err := apiIssueToPresentationIssue(tc.issue, nil) + if err != nil { + t.Fatal(err) + } + // These are only here for development purposes + assert.Equal(t, presentationIssue.Title, tc.expect.Title) + assert.Equal(t, presentationIssue.Number, tc.expect.Number) + assert.Equal(t, presentationIssue.CreatedAt, tc.expect.CreatedAt) + assert.Equal(t, presentationIssue.Comments, tc.expect.Comments) + assert.Equal(t, presentationIssue.State, tc.expect.State) + assert.Equal(t, presentationIssue.Reactions, tc.expect.Reactions) + assert.Equal(t, presentationIssue.AssigneesList, tc.expect.AssigneesList) + assert.Equal(t, presentationIssue.LabelsList, tc.expect.LabelsList) + // Below will fail until V2 support is added + // assert.Equal(t, presentationIssue.ProjectsList, tc.expect.ProjectsList) + assert.Equal(t, presentationIssue.MilestoneTitle, tc.expect.MilestoneTitle) + assert.Equal(t, presentationIssue.Body, tc.expect.Body) + assert.Equal(t, presentationIssue.URL, tc.expect.URL) + + // This is the actual test + // assert.Equal(t, presentationIssue, tc.expect) + }) + } +} + +// Placeholder. I'm not sure how I want to test this... +// func Test_ipf_renderHumanIssuePreview(t *testing.T) { +// return +// } + +func Test_ipf_RenderRawIssuePreview(t *testing.T) { + tests := map[string]struct { + presentationIssue *presentationIssue + expectedOutput string + }{ + "basic issue": { + presentationIssue: &presentationIssue{ + Title: "issueTitle", + State: "issueState", + Author: "authorLogin", + LabelsList: "labelsList", + Comments: api.Comments{ + TotalCount: 1, + }, + AssigneesList: "assigneesList", + ProjectsList: "projectsList", + MilestoneTitle: "milestoneTitle", + Number: 123, + Body: "issueBody", + }, + expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\tlabelsList\ncomments:\t1\nassignees:\tassigneesList\nprojects:\tprojectsList\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + ipf := NewIssuePrintFormatter(tc.presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.renderRawIssuePreview() + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} + +func Test_getAssigneeListString(t *testing.T) { + tests := map[string]struct { + assignees api.Assignees + expected string + }{ + "two assignees": { + assignees: api.Assignees{ + Nodes: []api.GitHubUser{ + {Login: "monalisa"}, + {Login: "hubot"}, + }, + TotalCount: 2, + }, + expected: "monalisa, hubot", + }, + "no assignees": { + assignees: api.Assignees{}, + expected: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, getAssigneeListString(tc.assignees)) + }) + } +} + +func Test_getColorizedLabelsList(t *testing.T) { + tests := map[string]struct { + labels api.Labels + isColorSchemeEnabled bool + expected string + }{ + "no labels": { + labels: api.Labels{}, + expected: "", + }, + "single label no colorScheme": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + }, + isColorSchemeEnabled: false, + expected: "bug", + }, + "single label with colorScheme": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + }, + isColorSchemeEnabled: true, + expected: "\033[38;2;252;3;3mbug\033[0m", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetColorEnabled(tc.isColorSchemeEnabled) + + assert.Equal(t, getColorizedLabelsList(tc.labels, ios.ColorScheme()), tc.expected) + }) + } +} + +func Test_getProjectListString(t *testing.T) { + tests := map[string]struct { + projectCards api.ProjectCards + projectItems api.ProjectItems + expected string + }{ + "no projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{}, + TotalCount: 0, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "", + }, + "two v1 projects and no v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: "Column 1"}}, + {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: "Column 2"}}, + }, + TotalCount: 2, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "Project 1 (Column 1), Project 2 (Column 2)", + }, + "two v1 projects without columns and no v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, + {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, + }, + TotalCount: 2, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "Project 1 (Awaiting triage), Project 2 (Awaiting triage)", + }, + "no v1 projects and 2 v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{}, + TotalCount: 0, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, getProjectListString(tc.projectCards, tc.projectItems)) + }) + } +} + +func Test_ipf_reactions(t *testing.T) { + tests := map[string]struct { + reactions string + expected string + }{ + "no reactions": { + reactions: "", + expected: "", + }, + "reactions": { + reactions: "a reaction", + expected: "a reaction\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + presentationIssue := &presentationIssue{ + Reactions: tc.reactions, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + ipf.reactions() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +// This does not test color, just output +func Test_ipf_header(t *testing.T) { + tests := map[string]struct { + title string + number int + state string + stateReason string + createdAt string + author string + baseRepo ghrepo.Interface + expected string + }{ + "simple open issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "OPEN", + stateReason: "", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nOpen • monalisa opened about 1 day ago • 1 comment\n", + }, + "simple closed issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "CLOSED", + stateReason: "COMPLETED", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nClosed • monalisa opened about 1 day ago • 1 comment\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + createdAtTime, err := time.Parse(time.RFC3339, tc.createdAt) + if err != nil { + t.Fatal(err) + } + + presentationIssue := &presentationIssue{ + Title: tc.title, + Number: tc.number, + Comments: api.Comments{ + TotalCount: 1, + }, + State: tc.state, + StateReason: tc.stateReason, + CreatedAt: createdAtTime, + Author: tc.author, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, createdAtTime.AddDate(0, 0, 1), tc.baseRepo) + ipf.header() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_assigneeList(t *testing.T) { + tests := map[string]struct { + assignees string + expected string + }{ + "no assignees": { + assignees: "", + expected: "", + }, + "assignees": { + assignees: "monalisa, octocat", + expected: "Assignees: monalisa, octocat\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + AssigneesList: tc.assignees, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.assigneeList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_labelList(t *testing.T) { + tests := map[string]struct { + labels string + expected string + }{ + "no labels": { + labels: "", + expected: "", + }, + "labels": { + labels: "bug, enhancement", + expected: "Labels: bug, enhancement\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + LabelsList: tc.labels, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.labelList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_projectList(t *testing.T) { + tests := map[string]struct { + projectList string + expected string + }{ + "no projects": { + projectList: "", + expected: "", + }, + "some projects": { + projectList: "ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)", + expected: "Projects: ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + ProjectsList: tc.projectList, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.projectList() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_milestone(t *testing.T) { + tests := map[string]struct { + milestone string + expected string + }{ + "no milestone": { + milestone: "", + expected: "", + }, + "milestone": { + milestone: "milestoneTitle", + expected: "Milestone: milestoneTitle\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + MilestoneTitle: tc.milestone, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.milestone() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_body(t *testing.T) { + tests := map[string]struct { + body string + expected string + }{ + "no body": { + body: "", + expected: "No description provided", + }, + "with body": { + body: "This is a body", + expected: "This is a body", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + Body: tc.body, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + err := ipf.body() + if err != nil { + t.Fatal(err) + } + // This is getting around whitespace issues I was having with assert.Equal + assert.Matches(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_comments(t *testing.T) { + test := map[string]struct { + commentNodes []api.Comment + expected string + isPreview bool + }{ + "no comments": { + commentNodes: []api.Comment{}, + expected: "", + isPreview: false, + }, + "comments": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", + isPreview: false, + }, + "preview": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", + isPreview: true, + }, + } + for name, tc := range test { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + for i := range tc.commentNodes { + // subtract a day + tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) + } + + presentationIssue := &presentationIssue{ + Comments: api.Comments{ + Nodes: tc.commentNodes, + TotalCount: len(tc.commentNodes), + }, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, timeNow, ghrepo.New("OWNER", "REPO")) + err = ipf.comments(tc.isPreview) + if err != nil { + t.Fatal(err) + } + + // I can't get these strings to match + // assert.Matches(t, stdout.String(), tc.expected) + }) + } +} + +func Test_ipf_footer(t *testing.T) { + tests := map[string]struct { + url string + expected string + }{ + "no url": { + url: "", + expected: "View this issue on GitHub: \n", + }, + "with url": { + url: "github.com/OWNER/REPO/issues/123", + expected: "View this issue on GitHub: github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &presentationIssue{ + URL: tc.url, + } + + ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) + ipf.footer() + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index ea3acfbee4b..a3e8d13462e 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "strings" "time" "github.com/MakeNowJust/heredoc" @@ -132,11 +131,16 @@ func viewRun(opts *ViewOptions) error { issue.Labels.SortAlphabeticallyIgnoreCase() - ipf := NewIssuePrintFormatter(issue, opts.IO, opts.Now(), baseRepo) + presentationIssue, err := apiIssueToPresentationIssue(issue, opts.IO.ColorScheme()) + if err != nil { + return err + } + + ipf := NewIssuePrintFormatter(presentationIssue, opts.IO, opts.Now(), baseRepo) if opts.IO.IsStdoutTTY() { isCommentsPreview := !opts.Comments - return humanIssuePreview(ipf, isCommentsPreview) + return ipf.renderHumanIssuePreview(isCommentsPreview) } if opts.Comments { @@ -144,7 +148,7 @@ func viewRun(opts *ViewOptions) error { return nil } - return rawIssuePreview(opts.IO, issue) + return ipf.renderRawIssuePreview() } func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { @@ -164,62 +168,3 @@ func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), } return issue, repo, err } - -func rawIssuePreview(IO *iostreams.IOStreams, issue *api.Issue) error { - - out := IO.Out - - assignees := issue.GetAssigneeListString() - // Labels no longer have color in the raw issue preview - labels := strings.Join(issue.Labels.Names(), ", ") - projects := issue.GetProjectListString() - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(out, "title:\t%s\n", issue.Title) - fmt.Fprintf(out, "state:\t%s\n", issue.State) - fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "projects:\t%s\n", projects) - var milestoneTitle string - if issue.Milestone != nil { - milestoneTitle = issue.Milestone.Title - } - fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) - fmt.Fprintf(out, "number:\t%d\n", issue.Number) - fmt.Fprintln(out, "--") - fmt.Fprintln(out, issue.Body) - return nil -} - -func humanIssuePreview(ipf *IssuePrintFormatter, isCommentsPreview bool) error { - - // header (Title and State) - ipf.header() - // Reactions - ipf.reactions() - // Metadata - ipf.assigneeList() - ipf.labelList() - ipf.projectList() - ipf.milestone() - - // Body - err := ipf.body() - if err != nil { - return err - } - - // Comments - err = ipf.comments(isCommentsPreview) - if err != nil { - return err - } - - // Footer - ipf.footer() - - return nil -} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 561b7f99157..6847d9178c6 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,15 +2,12 @@ package view import ( "bytes" - "encoding/json" "fmt" "io" "net/http" - "os" "testing" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -498,108 +495,3 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } - -// This is an Integration Test for the humanPreview -func Test_humanPreview(t *testing.T) { - tests := map[string]struct { - baseRepo ghrepo.Interface - issueFixture string - isCommentPreview bool - expectedOutput string - }{ - "projectcards included (v1 projects are supported)": { - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsEnabled.json", - isCommentPreview: true, - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nProjects: Project 1 (Column name)\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - "projectcards aren't included (v1 projects are supported)": { - baseRepo: ghrepo.New("OWNER", "REPO"), - issueFixture: "./fixtures/issueView_v1ProjectsDisabled.json", - isCommentPreview: true, - // This is super fragile but it's working for now - expectedOutput: "ix of coins OWNER/REPO#123\nOpen • marseilles opened about 1 day ago • 9 comments\nMilestone: \n\n\n **bold story** \n\n\n———————— Not showing 9 comments ————————\n\n\nUse --comments to view the full conversation\nView this issue on GitHub: https://github.com/OWNER/REPO/issues/123\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - issue := &api.Issue{} - - f, err := os.Open(tc.issueFixture) - if err != nil { - t.Errorf("Error opening fixture at %s: %v", tc.issueFixture, err) - } - defer f.Close() - - dec := json.NewDecoder(f) - err = dec.Decode(&issue) - if err != nil { - t.Errorf("Error decoding fixture at %s: %v", tc.issueFixture, err) - } - - ipf := NewIssuePrintFormatter(issue, ios, issue.CreatedAt.Add(24*time.Hour), tc.baseRepo) - - err = humanIssuePreview(ipf, tc.isCommentPreview) - if err != nil { - assert.NoError(t, err) - } - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} - -func Test_rawPreview(t *testing.T) { - tests := map[string]struct { - Issue *api.Issue - issueCreatedAt string - expectedOutput string - }{ - "basic issue": { - Issue: &api.Issue{ - Title: "issueTitle", - State: "issueState", - Author: api.Author{ - Login: "authorLogin", - }, - Comments: api.Comments{ - TotalCount: 1, - }, - Milestone: &api.Milestone{ - Title: "milestoneTitle", - }, - Number: 123, - Body: "issueBody", - }, - issueCreatedAt: "2011-01-26T19:01:12Z", - expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\t\ncomments:\t1\nassignees:\t\nprojects:\t\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - createdAt, err := time.Parse(time.RFC3339, "2011-01-26T19:01:12Z") - if err != nil { - t.Errorf("Error parsing time: %v", err) - } - - tc.Issue.CreatedAt = createdAt - - err = rawIssuePreview(ios, tc.Issue) - if err != nil { - assert.NoError(t, err) - } - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go index 02482951c5c..46ee9b9f0ec 100644 --- a/pkg/cmd/pr/shared/display.go +++ b/pkg/cmd/pr/shared/display.go @@ -41,12 +41,12 @@ func ColorForPRState(pr api.PullRequest) string { } } -func ColorForIssueState(issue api.Issue) string { - switch issue.State { +func ColorForIssueState(state string, stateReason string) string { + switch state { case "OPEN": return "green" case "CLOSED": - if issue.StateReason == "NOT_PLANNED" { + if stateReason == "NOT_PLANNED" { return "gray" } return "magenta" From bc8fdfcd3da026b6e80b99e7219f899366bd1b59 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 29 Aug 2024 20:06:49 +0200 Subject: [PATCH 10/21] WIP: Issue Printer Interface --- pkg/cmd/issue/view/issue_print_formatter.go | 10 +-- .../issue/view/issue_print_formatter_test.go | 26 ++++---- pkg/cmd/issue/view/view.go | 62 +++++++++++++++---- pkg/cmd/issue/view/view_test.go | 6 +- 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/issue/view/issue_print_formatter.go b/pkg/cmd/issue/view/issue_print_formatter.go index 731deb92e20..71bf1e8579e 100644 --- a/pkg/cmd/issue/view/issue_print_formatter.go +++ b/pkg/cmd/issue/view/issue_print_formatter.go @@ -14,14 +14,14 @@ import ( ) type IssuePrintFormatter struct { - presentationIssue *presentationIssue + presentationIssue *PresentationIssue colorScheme *iostreams.ColorScheme IO *iostreams.IOStreams time time.Time baseRepo ghrepo.Interface } -type presentationIssue struct { +type PresentationIssue struct { Title string Number int CreatedAt time.Time @@ -38,7 +38,7 @@ type presentationIssue struct { URL string } -func NewIssuePrintFormatter(presentationIssue *presentationIssue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { +func NewIssuePrintFormatter(presentationIssue *PresentationIssue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { return &IssuePrintFormatter{ presentationIssue: presentationIssue, colorScheme: IO.ColorScheme(), @@ -48,8 +48,8 @@ func NewIssuePrintFormatter(presentationIssue *presentationIssue, IO *iostreams. } } -func apiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (*presentationIssue, error) { - presentationIssue := &presentationIssue{ +func apiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (*PresentationIssue, error) { + presentationIssue := &PresentationIssue{ Title: issue.Title, Number: issue.Number, CreatedAt: issue.CreatedAt, diff --git a/pkg/cmd/issue/view/issue_print_formatter_test.go b/pkg/cmd/issue/view/issue_print_formatter_test.go index ebb7e171a83..808050f604a 100644 --- a/pkg/cmd/issue/view/issue_print_formatter_test.go +++ b/pkg/cmd/issue/view/issue_print_formatter_test.go @@ -18,7 +18,7 @@ func Test_apiIssueToPresentationIssue(t *testing.T) { tests := map[string]struct { issue *api.Issue - expect *presentationIssue + expect *PresentationIssue }{ "basic integration test": { issue: &api.Issue{ @@ -104,7 +104,7 @@ func Test_apiIssueToPresentationIssue(t *testing.T) { }, CreatedAt: createdAt, }, - expect: &presentationIssue{ + expect: &PresentationIssue{ Title: "Title", Number: 123, CreatedAt: createdAt, @@ -166,11 +166,11 @@ func Test_apiIssueToPresentationIssue(t *testing.T) { func Test_ipf_RenderRawIssuePreview(t *testing.T) { tests := map[string]struct { - presentationIssue *presentationIssue + presentationIssue *PresentationIssue expectedOutput string }{ "basic issue": { - presentationIssue: &presentationIssue{ + presentationIssue: &PresentationIssue{ Title: "issueTitle", State: "issueState", Author: "authorLogin", @@ -362,7 +362,7 @@ func Test_ipf_reactions(t *testing.T) { t.Fatal(err) } - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ Reactions: tc.reactions, } @@ -419,7 +419,7 @@ func Test_ipf_header(t *testing.T) { t.Fatal(err) } - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ Title: tc.title, Number: tc.number, Comments: api.Comments{ @@ -459,7 +459,7 @@ func Test_ipf_assigneeList(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ AssigneesList: tc.assignees, } @@ -491,7 +491,7 @@ func Test_ipf_labelList(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ LabelsList: tc.labels, } @@ -523,7 +523,7 @@ func Test_ipf_projectList(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ ProjectsList: tc.projectList, } @@ -555,7 +555,7 @@ func Test_ipf_milestone(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ MilestoneTitle: tc.milestone, } @@ -587,7 +587,7 @@ func Test_ipf_body(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ Body: tc.body, } @@ -657,7 +657,7 @@ func Test_ipf_comments(t *testing.T) { tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) } - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ Comments: api.Comments{ Nodes: tc.commentNodes, TotalCount: len(tc.commentNodes), @@ -697,7 +697,7 @@ func Test_ipf_footer(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) - presentationIssue := &presentationIssue{ + presentationIssue := &PresentationIssue{ URL: tc.url, } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index a3e8d13462e..b0bcd0fa53a 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -33,6 +33,40 @@ type ViewOptions struct { Exporter cmdutil.Exporter Now func() time.Time + + IssuePrinter IssuePrinter +} + +type IssuePrinter interface { + Print(*PresentationIssue, ghrepo.Interface) error +} + +type RawIssuePrinter struct { + IO *iostreams.IOStreams + TimeNow time.Time + Comments bool +} + +func (p *RawIssuePrinter) Print(issue *PresentationIssue, repo ghrepo.Interface) error { + if p.Comments { + fmt.Fprint(p.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{})) + return nil + } + + ipf := NewIssuePrintFormatter(issue, p.IO, p.TimeNow, repo) + return ipf.renderRawIssuePreview() +} + +type RichIssuePrinter struct { + IO *iostreams.IOStreams + TimeNow time.Time + Comments bool +} + +func (p *RichIssuePrinter) Print(issue *PresentationIssue, repo ghrepo.Interface) error { + ipf := NewIssuePrintFormatter(issue, p.IO, p.TimeNow, repo) + isCommentsPreview := !p.Comments + return ipf.renderHumanIssuePreview(isCommentsPreview) } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -60,6 +94,20 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts.SelectorArg = args[0] } + if opts.IO.IsStdoutTTY() { + opts.IssuePrinter = &RichIssuePrinter{ + IO: opts.IO, + TimeNow: opts.Now(), + Comments: opts.Comments, + } + } else { + opts.IssuePrinter = &RawIssuePrinter{ + IO: opts.IO, + TimeNow: opts.Now(), + Comments: opts.Comments, + } + } + if runF != nil { return runF(opts) } @@ -136,19 +184,7 @@ func viewRun(opts *ViewOptions) error { return err } - ipf := NewIssuePrintFormatter(presentationIssue, opts.IO, opts.Now(), baseRepo) - - if opts.IO.IsStdoutTTY() { - isCommentsPreview := !opts.Comments - return ipf.renderHumanIssuePreview(isCommentsPreview) - } - - if opts.Comments { - fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{})) - return nil - } - - return ipf.renderRawIssuePreview() + return opts.IssuePrinter.Print(presentationIssue, baseRepo) } func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string, detector fd.Detector) (*api.Issue, ghrepo.Interface, error) { diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 6847d9178c6..5df91d5abeb 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -265,11 +265,11 @@ func TestIssueView_tty_Preview(t *testing.T) { httpReg.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + stubbedTime, _ := time.Parse(time.RFC822, "03 Nov 20 15:04 UTC") opts := ViewOptions{ IO: ios, Now: func() time.Time { - t, _ := time.Parse(time.RFC822, "03 Nov 20 15:04 UTC") - return t + return stubbedTime }, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil @@ -279,6 +279,8 @@ func TestIssueView_tty_Preview(t *testing.T) { }, SelectorArg: "123", Detector: &featuredetection.EnabledDetectorMock{}, + + IssuePrinter: &RichIssuePrinter{IO: ios, TimeNow: stubbedTime}, } err := viewRun(&opts) From ec73f7c938eaf0132fee1b55893274e0277388ee Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 29 Aug 2024 20:26:01 +0200 Subject: [PATCH 11/21] WIP: test RawIssuePrinter --- api/queries_comments.go | 2 + pkg/cmd/issue/view/issue_print_formatter.go | 20 ------ .../issue/view/issue_print_formatter_test.go | 35 ----------- pkg/cmd/issue/view/view.go | 23 +++++-- pkg/cmd/issue/view/view_test.go | 61 +++++++++++++++++++ pkg/cmd/pr/shared/comments.go | 9 +++ 6 files changed, 89 insertions(+), 61 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index 5cc84a3e43f..69bb189ab73 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -139,6 +139,8 @@ func (c Comment) Reactions() ReactionGroups { return c.ReactionGroups } +// TODO: What is this supposed to do, and how does it relate to +// formatting comments for PR or Issue view? func (c Comment) Status() string { return "" } diff --git a/pkg/cmd/issue/view/issue_print_formatter.go b/pkg/cmd/issue/view/issue_print_formatter.go index 71bf1e8579e..cba307b346c 100644 --- a/pkg/cmd/issue/view/issue_print_formatter.go +++ b/pkg/cmd/issue/view/issue_print_formatter.go @@ -159,26 +159,6 @@ func (ipf *IssuePrintFormatter) renderHumanIssuePreview(isCommentsPreview bool) return nil } -func (ipf *IssuePrintFormatter) renderRawIssuePreview() error { - - out := ipf.IO.Out - pi := ipf.presentationIssue - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(out, "title:\t%s\n", pi.Title) - fmt.Fprintf(out, "state:\t%s\n", pi.State) - fmt.Fprintf(out, "author:\t%s\n", pi.Author) - fmt.Fprintf(out, "labels:\t%s\n", pi.LabelsList) - fmt.Fprintf(out, "comments:\t%d\n", pi.Comments.TotalCount) - fmt.Fprintf(out, "assignees:\t%s\n", pi.AssigneesList) - fmt.Fprintf(out, "projects:\t%s\n", pi.ProjectsList) - fmt.Fprintf(out, "milestone:\t%s\n", pi.MilestoneTitle) - fmt.Fprintf(out, "number:\t%d\n", pi.Number) - fmt.Fprintln(out, "--") - fmt.Fprintln(out, pi.Body) - return nil -} - func (i *IssuePrintFormatter) header() { fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.presentationIssue.Title), ghrepo.FullName(i.baseRepo), i.presentationIssue.Number) fmt.Fprintf(i.IO.Out, diff --git a/pkg/cmd/issue/view/issue_print_formatter_test.go b/pkg/cmd/issue/view/issue_print_formatter_test.go index 808050f604a..74189256658 100644 --- a/pkg/cmd/issue/view/issue_print_formatter_test.go +++ b/pkg/cmd/issue/view/issue_print_formatter_test.go @@ -164,41 +164,6 @@ func Test_apiIssueToPresentationIssue(t *testing.T) { // return // } -func Test_ipf_RenderRawIssuePreview(t *testing.T) { - tests := map[string]struct { - presentationIssue *PresentationIssue - expectedOutput string - }{ - "basic issue": { - presentationIssue: &PresentationIssue{ - Title: "issueTitle", - State: "issueState", - Author: "authorLogin", - LabelsList: "labelsList", - Comments: api.Comments{ - TotalCount: 1, - }, - AssigneesList: "assigneesList", - ProjectsList: "projectsList", - MilestoneTitle: "milestoneTitle", - Number: 123, - Body: "issueBody", - }, - expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\tlabelsList\ncomments:\t1\nassignees:\tassigneesList\nprojects:\tprojectsList\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - - ipf := NewIssuePrintFormatter(tc.presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.renderRawIssuePreview() - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} - func Test_getAssigneeListString(t *testing.T) { tests := map[string]struct { assignees api.Assignees diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b0bcd0fa53a..4ecae17ef3c 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -43,18 +43,30 @@ type IssuePrinter interface { type RawIssuePrinter struct { IO *iostreams.IOStreams - TimeNow time.Time Comments bool } -func (p *RawIssuePrinter) Print(issue *PresentationIssue, repo ghrepo.Interface) error { +func (p *RawIssuePrinter) Print(pi *PresentationIssue, repo ghrepo.Interface) error { if p.Comments { - fmt.Fprint(p.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{})) + fmt.Fprint(p.IO.Out, prShared.RawCommentList(pi.Comments, api.PullRequestReviews{})) return nil } - ipf := NewIssuePrintFormatter(issue, p.IO, p.TimeNow, repo) - return ipf.renderRawIssuePreview() + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(p.IO.Out, "title:\t%s\n", pi.Title) + fmt.Fprintf(p.IO.Out, "state:\t%s\n", pi.State) + fmt.Fprintf(p.IO.Out, "author:\t%s\n", pi.Author) + fmt.Fprintf(p.IO.Out, "labels:\t%s\n", pi.LabelsList) + fmt.Fprintf(p.IO.Out, "comments:\t%d\n", pi.Comments.TotalCount) + fmt.Fprintf(p.IO.Out, "assignees:\t%s\n", pi.AssigneesList) + fmt.Fprintf(p.IO.Out, "projects:\t%s\n", pi.ProjectsList) + fmt.Fprintf(p.IO.Out, "milestone:\t%s\n", pi.MilestoneTitle) + fmt.Fprintf(p.IO.Out, "number:\t%d\n", pi.Number) + fmt.Fprintln(p.IO.Out, "--") + fmt.Fprintln(p.IO.Out, pi.Body) + + return nil } type RichIssuePrinter struct { @@ -103,7 +115,6 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } else { opts.IssuePrinter = &RawIssuePrinter{ IO: opts.IO, - TimeNow: opts.Now(), Comments: opts.Comments, } } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 5df91d5abeb..f20539920bf 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -497,3 +498,63 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } + +func TestRawIssuePrinting(t *testing.T) { + tests := map[string]struct { + comments bool + presentationIssue *PresentationIssue + expectedOutput string + }{ + "basic issue, no comments requested": { + comments: false, + presentationIssue: &PresentationIssue{ + Title: "issueTitle", + State: "issueState", + Author: "authorLogin", + LabelsList: "labelsList", + Comments: api.Comments{ + TotalCount: 1, + }, + AssigneesList: "assigneesList", + ProjectsList: "projectsList", + MilestoneTitle: "milestoneTitle", + Number: 123, + Body: "issueBody", + }, + expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\tlabelsList\ncomments:\t1\nassignees:\tassigneesList\nprojects:\tprojectsList\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", + }, + "basic issue, displays only comments when requested": { + comments: true, + presentationIssue: &PresentationIssue{ + Comments: api.Comments{ + TotalCount: 1, + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "test-author", + }, + AuthorAssociation: "member", + Body: "comment body", + IncludesCreatedEdit: false, + }, + }, + }, + }, + expectedOutput: "author:\ttest-author\nassociation:\tmember\nedited:\tfalse\nstatus:\tnone\n--\ncomment body\n--\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + rip := &RawIssuePrinter{ + IO: ios, + Comments: tc.comments, + } + + rip.Print(tc.presentationIssue, ghrepo.New("OWNER", "REPO")) + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index a05108d7ba1..601bc711c41 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/pkg/markdown" ) +// TODO: can this interface be removed? type Comment interface { Identifier() string AuthorLogin() string @@ -26,6 +27,14 @@ type Comment interface { Status() string } +// type PresentationComment struct { +// AuthorLogin string +// Assocation string +// IsEdited bool +// Status string ???? +// Content string +// } + func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string { sortedComments := sortComments(comments, reviews) var b strings.Builder From 42031f77794e48255adc26d10a5c33ac67b3ed38 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 28 Aug 2024 16:24:10 -0700 Subject: [PATCH 12/21] Add v2 project support to getProjectListString --- pkg/cmd/issue/view/issue_print_formatter.go | 16 ++++-- .../issue/view/issue_print_formatter_test.go | 53 +++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/issue/view/issue_print_formatter.go b/pkg/cmd/issue/view/issue_print_formatter.go index cba307b346c..bae06f7c231 100644 --- a/pkg/cmd/issue/view/issue_print_formatter.go +++ b/pkg/cmd/issue/view/issue_print_formatter.go @@ -74,17 +74,25 @@ func apiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorS } func getProjectListString(projectCards api.ProjectCards, projectItems api.ProjectItems) string { - if len(projectCards.Nodes) == 0 { + if len(projectCards.Nodes) == 0 && len(projectItems.Nodes) == 0 { return "" } - projectNames := make([]string, 0, len(projectCards.Nodes)) - for _, project := range projectCards.Nodes { + projectNames := make([]string, len(projectCards.Nodes)+len(projectItems.Nodes)) + for i, project := range projectCards.Nodes { colName := project.Column.Name if colName == "" { colName = "Awaiting triage" } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + projectNames[i] = fmt.Sprintf("%s (%s)", project.Project.Name, colName) + } + + for i, project := range projectItems.Nodes { + statusName := project.Status.Name + if statusName == "" { + statusName = "Backlog" + } + projectNames[i+len(projectCards.Nodes)] = fmt.Sprintf("%s (%s)", project.Project.Title, statusName) } list := strings.Join(projectNames, ", ") diff --git a/pkg/cmd/issue/view/issue_print_formatter_test.go b/pkg/cmd/issue/view/issue_print_formatter_test.go index 74189256658..90c0d78f990 100644 --- a/pkg/cmd/issue/view/issue_print_formatter_test.go +++ b/pkg/cmd/issue/view/issue_print_formatter_test.go @@ -288,15 +288,62 @@ func Test_getProjectListString(t *testing.T) { TotalCount: 0, }, projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{}, + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + { + ID: "projectItemID2", + Project: api.ProjectV2ItemProject{ + ID: "projectID2", + Title: "V2 Project Title 2", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID2", + Name: "", + }, + }, + }, }, - expected: "", + expected: "V2 Project Title (STATUS), V2 Project Title 2 (Backlog)", + }, + "1 v1 project and 1 v2 project": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "V1 Project Name"}, Column: api.ProjectV1ProjectColumn{Name: "COLUMN"}}, + }, + TotalCount: 1, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + }, + }, + expected: "V1 Project Name (COLUMNS), V2 Project Title (STATUS)", }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.expected, getProjectListString(tc.projectCards, tc.projectItems)) + assert.Equal(t, getProjectListString(tc.projectCards, tc.projectItems), tc.expected) }) } } From 6d6aab5871452e25fbb2073ae6cdf9c6b0bd5ee1 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 29 Aug 2024 13:22:03 -0700 Subject: [PATCH 13/21] Refactor existing IssuePrintFormatter logic into RichIssuePrinter By pulling the decision logic for what will be printed up to the NewCmdView function, we were able to tease the functionality previously in the IssuePrintFormatter out and into two new structs. This completes the refactoring of the previous "human" readable printing into the RichIssuePrinter and separates the different data structures into their own files. Additionally, I've changed the PresentationIssue being passed around from a pointer to a copy of the object to avoid any unintended side-effects in the various methods --- pkg/cmd/issue/view/issue_print_formatter.go | 259 ------- .../issue/view/issue_print_formatter_test.go | 721 ------------------ pkg/cmd/issue/view/presentation_issue.go | 111 +++ pkg/cmd/issue/view/presentation_issue_test.go | 345 +++++++++ pkg/cmd/issue/view/raw_issue_printer.go | 38 + pkg/cmd/issue/view/raw_issue_printer_test.go | 70 ++ pkg/cmd/issue/view/rich_issue_printer.go | 145 ++++ pkg/cmd/issue/view/rich_issue_printer_test.go | 405 ++++++++++ pkg/cmd/issue/view/view.go | 45 +- pkg/cmd/issue/view/view_test.go | 61 -- 10 files changed, 1116 insertions(+), 1084 deletions(-) delete mode 100644 pkg/cmd/issue/view/issue_print_formatter.go delete mode 100644 pkg/cmd/issue/view/issue_print_formatter_test.go create mode 100644 pkg/cmd/issue/view/presentation_issue.go create mode 100644 pkg/cmd/issue/view/presentation_issue_test.go create mode 100644 pkg/cmd/issue/view/raw_issue_printer.go create mode 100644 pkg/cmd/issue/view/raw_issue_printer_test.go create mode 100644 pkg/cmd/issue/view/rich_issue_printer.go create mode 100644 pkg/cmd/issue/view/rich_issue_printer_test.go diff --git a/pkg/cmd/issue/view/issue_print_formatter.go b/pkg/cmd/issue/view/issue_print_formatter.go deleted file mode 100644 index bae06f7c231..00000000000 --- a/pkg/cmd/issue/view/issue_print_formatter.go +++ /dev/null @@ -1,259 +0,0 @@ -package view - -import ( - "fmt" - "strings" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/text" - prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" -) - -type IssuePrintFormatter struct { - presentationIssue *PresentationIssue - colorScheme *iostreams.ColorScheme - IO *iostreams.IOStreams - time time.Time - baseRepo ghrepo.Interface -} - -type PresentationIssue struct { - Title string - Number int - CreatedAt time.Time - Comments api.Comments - Author string - State string - StateReason string - Reactions string - AssigneesList string - LabelsList string - ProjectsList string - MilestoneTitle string - Body string - URL string -} - -func NewIssuePrintFormatter(presentationIssue *PresentationIssue, IO *iostreams.IOStreams, timeNow time.Time, baseRepo ghrepo.Interface) *IssuePrintFormatter { - return &IssuePrintFormatter{ - presentationIssue: presentationIssue, - colorScheme: IO.ColorScheme(), - IO: IO, - time: timeNow, - baseRepo: baseRepo, - } -} - -func apiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (*PresentationIssue, error) { - presentationIssue := &PresentationIssue{ - Title: issue.Title, - Number: issue.Number, - CreatedAt: issue.CreatedAt, - Comments: issue.Comments, - Author: issue.Author.Login, - State: issue.State, - StateReason: issue.StateReason, - Reactions: prShared.ReactionGroupList(issue.ReactionGroups), - AssigneesList: getAssigneeListString(issue.Assignees), - // It feels weird to add color here... - LabelsList: getColorizedLabelsList(issue.Labels, colorScheme), - ProjectsList: getProjectListString(issue.ProjectCards, issue.ProjectItems), - Body: issue.Body, - URL: issue.URL, - } - - if issue.Milestone != nil { - presentationIssue.MilestoneTitle = issue.Milestone.Title - } - - return presentationIssue, nil -} - -func getProjectListString(projectCards api.ProjectCards, projectItems api.ProjectItems) string { - if len(projectCards.Nodes) == 0 && len(projectItems.Nodes) == 0 { - return "" - } - - projectNames := make([]string, len(projectCards.Nodes)+len(projectItems.Nodes)) - for i, project := range projectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames[i] = fmt.Sprintf("%s (%s)", project.Project.Name, colName) - } - - for i, project := range projectItems.Nodes { - statusName := project.Status.Name - if statusName == "" { - statusName = "Backlog" - } - projectNames[i+len(projectCards.Nodes)] = fmt.Sprintf("%s (%s)", project.Project.Title, statusName) - } - - list := strings.Join(projectNames, ", ") - if projectCards.TotalCount > len(projectCards.Nodes) { - list += ", …" - } - return list -} - -func getAssigneeListString(issueAssignees api.Assignees) string { - if len(issueAssignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(issueAssignees.Nodes)) - for _, assignee := range issueAssignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if issueAssignees.TotalCount > len(issueAssignees.Nodes) { - list += ", …" - } - return list -} - -func getColorizedLabelsList(issueLabels api.Labels, colorScheme *iostreams.ColorScheme) string { - labelNames := make([]string, len(issueLabels.Nodes)) - for j, label := range issueLabels.Nodes { - if colorScheme == nil { - labelNames[j] = label.Name - } else { - labelNames[j] = colorScheme.HexToRGB(label.Color, label.Name) - } - } - - return strings.Join(labelNames, ", ") -} - -func (ipf *IssuePrintFormatter) renderHumanIssuePreview(isCommentsPreview bool) error { - - // I think I'd like to make this easier to understand what the output should look like. - // That's probably doable by removing these helpers and just using a formatted string. - // I might experiment with that later. - - // header (Title and State) - ipf.header() - // Reactions - ipf.reactions() - // Metadata - ipf.assigneeList() - ipf.labelList() - ipf.projectList() - - ipf.milestone() - - // Body - err := ipf.body() - if err != nil { - return err - } - - // Comments - err = ipf.comments(isCommentsPreview) - if err != nil { - return err - } - - // Footer - ipf.footer() - - return nil -} - -func (i *IssuePrintFormatter) header() { - fmt.Fprintf(i.IO.Out, "%s %s#%d\n", i.colorScheme.Bold(i.presentationIssue.Title), ghrepo.FullName(i.baseRepo), i.presentationIssue.Number) - fmt.Fprintf(i.IO.Out, - "%s • %s opened %s • %s\n", - i.issueStateTitleWithColor(), - i.presentationIssue.Author, - text.FuzzyAgo(i.time, i.presentationIssue.CreatedAt), - text.Pluralize(i.presentationIssue.Comments.TotalCount, "comment"), - ) -} - -func (i *IssuePrintFormatter) issueStateTitleWithColor() string { - colorFunc := i.colorScheme.ColorFromString(prShared.ColorForIssueState(i.presentationIssue.State, i.presentationIssue.StateReason)) - state := "Open" - if i.presentationIssue.State == "CLOSED" { - state = "Closed" - } - return colorFunc(state) -} - -func (i *IssuePrintFormatter) reactions() { - if i.presentationIssue.Reactions != "" { - fmt.Fprint(i.IO.Out, i.presentationIssue.Reactions) - fmt.Fprintln(i.IO.Out) - } -} - -func (i *IssuePrintFormatter) assigneeList() { - assignees := i.presentationIssue.AssigneesList - if assignees != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Assignees: ")) - fmt.Fprintln(i.IO.Out, assignees) - } -} - -func (i *IssuePrintFormatter) labelList() { - labels := i.presentationIssue.LabelsList - if labels != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Labels: ")) - fmt.Fprintln(i.IO.Out, labels) - } -} - -func (i *IssuePrintFormatter) projectList() { - projects := i.presentationIssue.ProjectsList - if projects != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Projects: ")) - fmt.Fprintln(i.IO.Out, projects) - } -} - -func (i *IssuePrintFormatter) milestone() { - if i.presentationIssue.MilestoneTitle != "" { - fmt.Fprint(i.IO.Out, i.colorScheme.Bold("Milestone: ")) - fmt.Fprintln(i.IO.Out, i.presentationIssue.MilestoneTitle) - } -} - -func (i *IssuePrintFormatter) body() error { - var md string - var err error - body := i.presentationIssue.Body - if body == "" { - md = fmt.Sprintf("\n %s\n\n", i.colorScheme.Gray("No description provided")) - } else { - md, err = markdown.Render(body, - markdown.WithTheme(i.IO.TerminalTheme()), - markdown.WithWrap(i.IO.TerminalWidth())) - if err != nil { - return err - } - } - fmt.Fprintf(i.IO.Out, "\n%s\n", md) - return nil -} - -func (i *IssuePrintFormatter) comments(isPreview bool) error { - if i.presentationIssue.Comments.TotalCount > 0 { - comments, err := prShared.CommentList(i.IO, i.presentationIssue.Comments, api.PullRequestReviews{}, isPreview) - if err != nil { - return err - } - fmt.Fprint(i.IO.Out, comments) - } - return nil -} - -func (i *IssuePrintFormatter) footer() { - fmt.Fprintf(i.IO.Out, i.colorScheme.Gray("View this issue on GitHub: %s\n"), i.presentationIssue.URL) -} diff --git a/pkg/cmd/issue/view/issue_print_formatter_test.go b/pkg/cmd/issue/view/issue_print_formatter_test.go deleted file mode 100644 index 90c0d78f990..00000000000 --- a/pkg/cmd/issue/view/issue_print_formatter_test.go +++ /dev/null @@ -1,721 +0,0 @@ -package view - -import ( - "testing" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/magiconair/properties/assert" -) - -func Test_apiIssueToPresentationIssue(t *testing.T) { - createdAt, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - tests := map[string]struct { - issue *api.Issue - expect *PresentationIssue - }{ - "basic integration test": { - issue: &api.Issue{ - Title: "Title", - Number: 123, - Comments: api.Comments{ - Nodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "comment body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - TotalCount: 1, - }, - State: "OPEN", - StateReason: "", - URL: "github.com/OWNER/REPO/issues/123", - Author: api.Author{ - Login: "octocat", - Name: "Octo Cat", - ID: "321", - }, - Assignees: api.Assignees{ - Nodes: []api.GitHubUser{ - { - Login: "octocat", - Name: "Octo Cat", - ID: "321", - }, - }, - TotalCount: 1, - }, - Labels: api.Labels{ - Nodes: []api.IssueLabel{ - { - Name: "bug", - Color: "fc0303", - }, - }, - TotalCount: 1, - }, - ProjectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{ - { - Project: api.ProjectV1ProjectName{ - Name: "ProjectCardName", - }, - Column: api.ProjectV1ProjectColumn{ - Name: "ProjectCardColumn", - }, - }, - }, - TotalCount: 1, - }, - ProjectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{ - { - ID: "projectItemID", - Project: api.ProjectV2ItemProject{ - ID: "projectID", - Title: "V2 Project Title", - }, - Status: api.ProjectV2ItemStatus{ - OptionID: "statusID", - Name: "STATUS", - }, - }, - }, - }, - Milestone: &api.Milestone{ - Title: "MilestoneTitle", - }, - ReactionGroups: api.ReactionGroups{ - { - Content: "THUMBS_UP", - Users: api.ReactionGroupUsers{ - TotalCount: 1, - }, - }, - }, - CreatedAt: createdAt, - }, - expect: &PresentationIssue{ - Title: "Title", - Number: 123, - CreatedAt: createdAt, - Comments: api.Comments{ - Nodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "comment body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - TotalCount: 1, - }, - State: "OPEN", - StateReason: "", - Reactions: "1 \U0001f44d", - AssigneesList: "octocat", - LabelsList: "bug", - ProjectsList: "ProjectCardName (ProjectCardColumn), V2ProjectName", - MilestoneTitle: "MilestoneTitle", - Body: "", - URL: "github.com/OWNER/REPO/issues/123", - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - presentationIssue, err := apiIssueToPresentationIssue(tc.issue, nil) - if err != nil { - t.Fatal(err) - } - // These are only here for development purposes - assert.Equal(t, presentationIssue.Title, tc.expect.Title) - assert.Equal(t, presentationIssue.Number, tc.expect.Number) - assert.Equal(t, presentationIssue.CreatedAt, tc.expect.CreatedAt) - assert.Equal(t, presentationIssue.Comments, tc.expect.Comments) - assert.Equal(t, presentationIssue.State, tc.expect.State) - assert.Equal(t, presentationIssue.Reactions, tc.expect.Reactions) - assert.Equal(t, presentationIssue.AssigneesList, tc.expect.AssigneesList) - assert.Equal(t, presentationIssue.LabelsList, tc.expect.LabelsList) - // Below will fail until V2 support is added - // assert.Equal(t, presentationIssue.ProjectsList, tc.expect.ProjectsList) - assert.Equal(t, presentationIssue.MilestoneTitle, tc.expect.MilestoneTitle) - assert.Equal(t, presentationIssue.Body, tc.expect.Body) - assert.Equal(t, presentationIssue.URL, tc.expect.URL) - - // This is the actual test - // assert.Equal(t, presentationIssue, tc.expect) - }) - } -} - -// Placeholder. I'm not sure how I want to test this... -// func Test_ipf_renderHumanIssuePreview(t *testing.T) { -// return -// } - -func Test_getAssigneeListString(t *testing.T) { - tests := map[string]struct { - assignees api.Assignees - expected string - }{ - "two assignees": { - assignees: api.Assignees{ - Nodes: []api.GitHubUser{ - {Login: "monalisa"}, - {Login: "hubot"}, - }, - TotalCount: 2, - }, - expected: "monalisa, hubot", - }, - "no assignees": { - assignees: api.Assignees{}, - expected: "", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.expected, getAssigneeListString(tc.assignees)) - }) - } -} - -func Test_getColorizedLabelsList(t *testing.T) { - tests := map[string]struct { - labels api.Labels - isColorSchemeEnabled bool - expected string - }{ - "no labels": { - labels: api.Labels{}, - expected: "", - }, - "single label no colorScheme": { - labels: api.Labels{ - Nodes: []api.IssueLabel{ - { - Name: "bug", - Color: "fc0303", - }, - }, - }, - isColorSchemeEnabled: false, - expected: "bug", - }, - "single label with colorScheme": { - labels: api.Labels{ - Nodes: []api.IssueLabel{ - { - Name: "bug", - Color: "fc0303", - }, - }, - }, - isColorSchemeEnabled: true, - expected: "\033[38;2;252;3;3mbug\033[0m", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - ios.SetColorEnabled(tc.isColorSchemeEnabled) - - assert.Equal(t, getColorizedLabelsList(tc.labels, ios.ColorScheme()), tc.expected) - }) - } -} - -func Test_getProjectListString(t *testing.T) { - tests := map[string]struct { - projectCards api.ProjectCards - projectItems api.ProjectItems - expected string - }{ - "no projects": { - projectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{}, - TotalCount: 0, - }, - projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{}, - }, - expected: "", - }, - "two v1 projects and no v2 projects": { - projectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{ - {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: "Column 1"}}, - {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: "Column 2"}}, - }, - TotalCount: 2, - }, - projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{}, - }, - expected: "Project 1 (Column 1), Project 2 (Column 2)", - }, - "two v1 projects without columns and no v2 projects": { - projectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{ - {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, - {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, - }, - TotalCount: 2, - }, - projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{}, - }, - expected: "Project 1 (Awaiting triage), Project 2 (Awaiting triage)", - }, - "no v1 projects and 2 v2 projects": { - projectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{}, - TotalCount: 0, - }, - projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{ - { - ID: "projectItemID", - Project: api.ProjectV2ItemProject{ - ID: "projectID", - Title: "V2 Project Title", - }, - Status: api.ProjectV2ItemStatus{ - OptionID: "statusID", - Name: "STATUS", - }, - }, - { - ID: "projectItemID2", - Project: api.ProjectV2ItemProject{ - ID: "projectID2", - Title: "V2 Project Title 2", - }, - Status: api.ProjectV2ItemStatus{ - OptionID: "statusID2", - Name: "", - }, - }, - }, - }, - expected: "V2 Project Title (STATUS), V2 Project Title 2 (Backlog)", - }, - "1 v1 project and 1 v2 project": { - projectCards: api.ProjectCards{ - Nodes: []*api.ProjectInfo{ - {Project: api.ProjectV1ProjectName{Name: "V1 Project Name"}, Column: api.ProjectV1ProjectColumn{Name: "COLUMN"}}, - }, - TotalCount: 1, - }, - projectItems: api.ProjectItems{ - Nodes: []*api.ProjectV2Item{ - { - ID: "projectItemID", - Project: api.ProjectV2ItemProject{ - ID: "projectID", - Title: "V2 Project Title", - }, - Status: api.ProjectV2ItemStatus{ - OptionID: "statusID", - Name: "STATUS", - }, - }, - }, - }, - expected: "V1 Project Name (COLUMNS), V2 Project Title (STATUS)", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - assert.Equal(t, getProjectListString(tc.projectCards, tc.projectItems), tc.expected) - }) - } -} - -func Test_ipf_reactions(t *testing.T) { - tests := map[string]struct { - reactions string - expected string - }{ - "no reactions": { - reactions: "", - expected: "", - }, - "reactions": { - reactions: "a reaction", - expected: "a reaction\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - presentationIssue := &PresentationIssue{ - Reactions: tc.reactions, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - ipf.reactions() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -// This does not test color, just output -func Test_ipf_header(t *testing.T) { - tests := map[string]struct { - title string - number int - state string - stateReason string - createdAt string - author string - baseRepo ghrepo.Interface - expected string - }{ - "simple open issue": { - title: "Simple Issue Test", - baseRepo: ghrepo.New("OWNER", "REPO"), - number: 123, - state: "OPEN", - stateReason: "", - author: "monalisa", - createdAt: "2022-01-01T00:00:00Z", - expected: "Simple Issue Test OWNER/REPO#123\nOpen • monalisa opened about 1 day ago • 1 comment\n", - }, - "simple closed issue": { - title: "Simple Issue Test", - baseRepo: ghrepo.New("OWNER", "REPO"), - number: 123, - state: "CLOSED", - stateReason: "COMPLETED", - author: "monalisa", - createdAt: "2022-01-01T00:00:00Z", - expected: "Simple Issue Test OWNER/REPO#123\nClosed • monalisa opened about 1 day ago • 1 comment\n", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - createdAtTime, err := time.Parse(time.RFC3339, tc.createdAt) - if err != nil { - t.Fatal(err) - } - - presentationIssue := &PresentationIssue{ - Title: tc.title, - Number: tc.number, - Comments: api.Comments{ - TotalCount: 1, - }, - State: tc.state, - StateReason: tc.stateReason, - CreatedAt: createdAtTime, - Author: tc.author, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, createdAtTime.AddDate(0, 0, 1), tc.baseRepo) - ipf.header() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_assigneeList(t *testing.T) { - tests := map[string]struct { - assignees string - expected string - }{ - "no assignees": { - assignees: "", - expected: "", - }, - "assignees": { - assignees: "monalisa, octocat", - expected: "Assignees: monalisa, octocat\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - AssigneesList: tc.assignees, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.assigneeList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_labelList(t *testing.T) { - tests := map[string]struct { - labels string - expected string - }{ - "no labels": { - labels: "", - expected: "", - }, - "labels": { - labels: "bug, enhancement", - expected: "Labels: bug, enhancement\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - LabelsList: tc.labels, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.labelList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_projectList(t *testing.T) { - tests := map[string]struct { - projectList string - expected string - }{ - "no projects": { - projectList: "", - expected: "", - }, - "some projects": { - projectList: "ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)", - expected: "Projects: ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - ProjectsList: tc.projectList, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.projectList() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_milestone(t *testing.T) { - tests := map[string]struct { - milestone string - expected string - }{ - "no milestone": { - milestone: "", - expected: "", - }, - "milestone": { - milestone: "milestoneTitle", - expected: "Milestone: milestoneTitle\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - MilestoneTitle: tc.milestone, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.milestone() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_body(t *testing.T) { - tests := map[string]struct { - body string - expected string - }{ - "no body": { - body: "", - expected: "No description provided", - }, - "with body": { - body: "This is a body", - expected: "This is a body", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - Body: tc.body, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - err := ipf.body() - if err != nil { - t.Fatal(err) - } - // This is getting around whitespace issues I was having with assert.Equal - assert.Matches(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_comments(t *testing.T) { - test := map[string]struct { - commentNodes []api.Comment - expected string - isPreview bool - }{ - "no comments": { - commentNodes: []api.Comment{}, - expected: "", - isPreview: false, - }, - "comments": { - commentNodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", - isPreview: false, - }, - "preview": { - commentNodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "monalisa", - }, - Body: "body 1", - ReactionGroups: api.ReactionGroups{}, - }, - }, - expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", - isPreview: true, - }, - } - for name, tc := range test { - t.Run(name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - for i := range tc.commentNodes { - // subtract a day - tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) - } - - presentationIssue := &PresentationIssue{ - Comments: api.Comments{ - Nodes: tc.commentNodes, - TotalCount: len(tc.commentNodes), - }, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, timeNow, ghrepo.New("OWNER", "REPO")) - err = ipf.comments(tc.isPreview) - if err != nil { - t.Fatal(err) - } - - // I can't get these strings to match - // assert.Matches(t, stdout.String(), tc.expected) - }) - } -} - -func Test_ipf_footer(t *testing.T) { - tests := map[string]struct { - url string - expected string - }{ - "no url": { - url: "", - expected: "View this issue on GitHub: \n", - }, - "with url": { - url: "github.com/OWNER/REPO/issues/123", - expected: "View this issue on GitHub: github.com/OWNER/REPO/issues/123\n", - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - presentationIssue := &PresentationIssue{ - URL: tc.url, - } - - ipf := NewIssuePrintFormatter(presentationIssue, ios, time.Time{}, ghrepo.New("OWNER", "REPO")) - ipf.footer() - assert.Equal(t, stdout.String(), tc.expected) - }) - } -} diff --git a/pkg/cmd/issue/view/presentation_issue.go b/pkg/cmd/issue/view/presentation_issue.go new file mode 100644 index 00000000000..5b4e8c75da6 --- /dev/null +++ b/pkg/cmd/issue/view/presentation_issue.go @@ -0,0 +1,111 @@ +package view + +import ( + "fmt" + "strings" + "time" + + "github.com/cli/cli/v2/api" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/iostreams" +) + +type PresentationIssue struct { + Title string + Number int + CreatedAt time.Time + Comments api.Comments + Author string + State string + StateReason string + Reactions string + AssigneesList string + LabelsList string + ProjectsList string + MilestoneTitle string + Body string + URL string +} + +func MapApiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (PresentationIssue, error) { + presentationIssue := PresentationIssue{ + Title: issue.Title, + Number: issue.Number, + CreatedAt: issue.CreatedAt, + Comments: issue.Comments, + Author: issue.Author.Login, + State: issue.State, + StateReason: issue.StateReason, + Reactions: prShared.ReactionGroupList(issue.ReactionGroups), + AssigneesList: getAssigneeListString(issue.Assignees), + LabelsList: getColorizedLabelsList(issue.Labels, colorScheme), + ProjectsList: getProjectListString(issue.ProjectCards, issue.ProjectItems), + Body: issue.Body, + URL: issue.URL, + } + + if issue.Milestone != nil { + presentationIssue.MilestoneTitle = issue.Milestone.Title + } + + return presentationIssue, nil +} + +func getProjectListString(projectCards api.ProjectCards, projectItems api.ProjectItems) string { + if len(projectCards.Nodes) == 0 && len(projectItems.Nodes) == 0 { + return "" + } + + projectNames := make([]string, len(projectCards.Nodes)+len(projectItems.Nodes)) + for i, project := range projectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames[i] = fmt.Sprintf("%s (%s)", project.Project.Name, colName) + } + + for i, project := range projectItems.Nodes { + statusName := project.Status.Name + if statusName == "" { + statusName = "Backlog" + } + projectNames[i+len(projectCards.Nodes)] = fmt.Sprintf("%s (%s)", project.Project.Title, statusName) + } + + list := strings.Join(projectNames, ", ") + if projectCards.TotalCount > len(projectCards.Nodes) { + list += ", …" + } + return list +} + +func getAssigneeListString(issueAssignees api.Assignees) string { + if len(issueAssignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(issueAssignees.Nodes)) + for _, assignee := range issueAssignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if issueAssignees.TotalCount > len(issueAssignees.Nodes) { + list += ", …" + } + return list +} + +func getColorizedLabelsList(issueLabels api.Labels, colorScheme *iostreams.ColorScheme) string { + labelNames := make([]string, len(issueLabels.Nodes)) + for j, label := range issueLabels.Nodes { + if colorScheme == nil { + labelNames[j] = label.Name + } else { + labelNames[j] = colorScheme.HexToRGB(label.Color, label.Name) + } + } + + return strings.Join(labelNames, ", ") +} diff --git a/pkg/cmd/issue/view/presentation_issue_test.go b/pkg/cmd/issue/view/presentation_issue_test.go new file mode 100644 index 00000000000..d9f20c2fcd8 --- /dev/null +++ b/pkg/cmd/issue/view/presentation_issue_test.go @@ -0,0 +1,345 @@ +package view + +import ( + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/magiconair/properties/assert" +) + +func Test_MapApiIssueToPresentationIssue(t *testing.T) { + createdAt, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + issue *api.Issue + expect PresentationIssue + }{ + "basic integration test": { + issue: &api.Issue{ + Title: "Title", + Number: 123, + Comments: api.Comments{ + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "comment body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + TotalCount: 1, + }, + State: "OPEN", + StateReason: "", + URL: "github.com/OWNER/REPO/issues/123", + Author: api.Author{ + Login: "author", + Name: "Octo Cat", + ID: "321", + }, + Assignees: api.Assignees{ + Nodes: []api.GitHubUser{ + { + Login: "assignee", + Name: "Octo Cat", + ID: "321", + }, + }, + TotalCount: 1, + }, + Labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + TotalCount: 1, + }, + ProjectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + { + Project: api.ProjectV1ProjectName{ + Name: "ProjectCardName", + }, + Column: api.ProjectV1ProjectColumn{ + Name: "ProjectCardColumn", + }, + }, + }, + TotalCount: 1, + }, + ProjectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + }, + }, + Milestone: &api.Milestone{ + Title: "MilestoneTitle", + }, + ReactionGroups: api.ReactionGroups{ + { + Content: "THUMBS_UP", + Users: api.ReactionGroupUsers{ + TotalCount: 1, + }, + }, + }, + CreatedAt: createdAt, + }, + expect: PresentationIssue{ + Title: "Title", + Number: 123, + CreatedAt: createdAt, + Comments: api.Comments{ + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "comment body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + TotalCount: 1, + }, + State: "OPEN", + StateReason: "", + Reactions: "1 \U0001f44d", + Author: "author", + AssigneesList: "assignee", + LabelsList: "bug", + ProjectsList: "ProjectCardName (ProjectCardColumn), V2 Project Title (STATUS)", + MilestoneTitle: "MilestoneTitle", + Body: "", + URL: "github.com/OWNER/REPO/issues/123", + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + presentationIssue, err := MapApiIssueToPresentationIssue(tc.issue, nil) + if err != nil { + t.Fatal(err) + } + + // These are here for development purposes + assert.Equal(t, presentationIssue.Title, tc.expect.Title) + assert.Equal(t, presentationIssue.Number, tc.expect.Number) + assert.Equal(t, presentationIssue.CreatedAt, tc.expect.CreatedAt) + assert.Equal(t, presentationIssue.Comments, tc.expect.Comments) + assert.Equal(t, presentationIssue.State, tc.expect.State) + assert.Equal(t, presentationIssue.Reactions, tc.expect.Reactions) + assert.Equal(t, presentationIssue.Author, tc.expect.Author) + assert.Equal(t, presentationIssue.AssigneesList, tc.expect.AssigneesList) + assert.Equal(t, presentationIssue.LabelsList, tc.expect.LabelsList) + assert.Equal(t, presentationIssue.ProjectsList, tc.expect.ProjectsList) + assert.Equal(t, presentationIssue.MilestoneTitle, tc.expect.MilestoneTitle) + assert.Equal(t, presentationIssue.Body, tc.expect.Body) + assert.Equal(t, presentationIssue.URL, tc.expect.URL) + + // This is the actual test + assert.Equal(t, presentationIssue, tc.expect) + }) + } +} + +func Test_getAssigneeListString(t *testing.T) { + tests := map[string]struct { + assignees api.Assignees + expected string + }{ + "two assignees": { + assignees: api.Assignees{ + Nodes: []api.GitHubUser{ + {Login: "monalisa"}, + {Login: "hubot"}, + }, + TotalCount: 2, + }, + expected: "monalisa, hubot", + }, + "no assignees": { + assignees: api.Assignees{}, + expected: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, getAssigneeListString(tc.assignees)) + }) + } +} + +func Test_getColorizedLabelsList(t *testing.T) { + tests := map[string]struct { + labels api.Labels + isColorSchemeEnabled bool + expected string + }{ + "no labels": { + labels: api.Labels{}, + expected: "", + }, + "single label no colorScheme": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + }, + isColorSchemeEnabled: false, + expected: "bug", + }, + "single label with colorScheme": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + { + Name: "bug", + Color: "fc0303", + }, + }, + }, + isColorSchemeEnabled: true, + expected: "\033[38;2;252;3;3mbug\033[0m", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetColorEnabled(tc.isColorSchemeEnabled) + + assert.Equal(t, getColorizedLabelsList(tc.labels, ios.ColorScheme()), tc.expected) + }) + } +} + +func Test_getProjectListString(t *testing.T) { + tests := map[string]struct { + projectCards api.ProjectCards + projectItems api.ProjectItems + expected string + }{ + "no projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{}, + TotalCount: 0, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "", + }, + "two v1 projects and no v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: "Column 1"}}, + {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: "Column 2"}}, + }, + TotalCount: 2, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "Project 1 (Column 1), Project 2 (Column 2)", + }, + "two v1 projects without columns and no v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "Project 1"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, + {Project: api.ProjectV1ProjectName{Name: "Project 2"}, Column: api.ProjectV1ProjectColumn{Name: ""}}, + }, + TotalCount: 2, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{}, + }, + expected: "Project 1 (Awaiting triage), Project 2 (Awaiting triage)", + }, + "no v1 projects and 2 v2 projects": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{}, + TotalCount: 0, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + { + ID: "projectItemID2", + Project: api.ProjectV2ItemProject{ + ID: "projectID2", + Title: "V2 Project Title 2", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID2", + Name: "", + }, + }, + }, + }, + expected: "V2 Project Title (STATUS), V2 Project Title 2 (Backlog)", + }, + "1 v1 project and 1 v2 project": { + projectCards: api.ProjectCards{ + Nodes: []*api.ProjectInfo{ + {Project: api.ProjectV1ProjectName{Name: "V1 Project Name"}, Column: api.ProjectV1ProjectColumn{Name: "COLUMN"}}, + }, + TotalCount: 1, + }, + projectItems: api.ProjectItems{ + Nodes: []*api.ProjectV2Item{ + { + ID: "projectItemID", + Project: api.ProjectV2ItemProject{ + ID: "projectID", + Title: "V2 Project Title", + }, + Status: api.ProjectV2ItemStatus{ + OptionID: "statusID", + Name: "STATUS", + }, + }, + }, + }, + expected: "V1 Project Name (COLUMN), V2 Project Title (STATUS)", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, getProjectListString(tc.projectCards, tc.projectItems), tc.expected) + }) + } +} diff --git a/pkg/cmd/issue/view/raw_issue_printer.go b/pkg/cmd/issue/view/raw_issue_printer.go new file mode 100644 index 00000000000..870bc798fd1 --- /dev/null +++ b/pkg/cmd/issue/view/raw_issue_printer.go @@ -0,0 +1,38 @@ +package view + +import ( + "fmt" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/iostreams" +) + +type RawIssuePrinter struct { + IO *iostreams.IOStreams + Comments bool +} + +func (p *RawIssuePrinter) Print(pi PresentationIssue, repo ghrepo.Interface) error { + if p.Comments { + fmt.Fprint(p.IO.Out, prShared.RawCommentList(pi.Comments, api.PullRequestReviews{})) + return nil + } + + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(p.IO.Out, "title:\t%s\n", pi.Title) + fmt.Fprintf(p.IO.Out, "state:\t%s\n", pi.State) + fmt.Fprintf(p.IO.Out, "author:\t%s\n", pi.Author) + fmt.Fprintf(p.IO.Out, "labels:\t%s\n", pi.LabelsList) + fmt.Fprintf(p.IO.Out, "comments:\t%d\n", pi.Comments.TotalCount) + fmt.Fprintf(p.IO.Out, "assignees:\t%s\n", pi.AssigneesList) + fmt.Fprintf(p.IO.Out, "projects:\t%s\n", pi.ProjectsList) + fmt.Fprintf(p.IO.Out, "milestone:\t%s\n", pi.MilestoneTitle) + fmt.Fprintf(p.IO.Out, "number:\t%d\n", pi.Number) + fmt.Fprintln(p.IO.Out, "--") + fmt.Fprintln(p.IO.Out, pi.Body) + + return nil +} diff --git a/pkg/cmd/issue/view/raw_issue_printer_test.go b/pkg/cmd/issue/view/raw_issue_printer_test.go new file mode 100644 index 00000000000..10ab560b336 --- /dev/null +++ b/pkg/cmd/issue/view/raw_issue_printer_test.go @@ -0,0 +1,70 @@ +package view + +import ( + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/magiconair/properties/assert" +) + +func TestRawIssuePrinting(t *testing.T) { + tests := map[string]struct { + comments bool + presentationIssue PresentationIssue + expectedOutput string + }{ + "basic issue, no comments requested": { + comments: false, + presentationIssue: PresentationIssue{ + Title: "issueTitle", + State: "issueState", + Author: "authorLogin", + LabelsList: "labelsList", + Comments: api.Comments{ + TotalCount: 1, + }, + AssigneesList: "assigneesList", + ProjectsList: "projectsList", + MilestoneTitle: "milestoneTitle", + Number: 123, + Body: "issueBody", + }, + expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\tlabelsList\ncomments:\t1\nassignees:\tassigneesList\nprojects:\tprojectsList\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", + }, + "basic issue, displays only comments when requested": { + comments: true, + presentationIssue: PresentationIssue{ + Comments: api.Comments{ + TotalCount: 1, + Nodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "test-author", + }, + AuthorAssociation: "member", + Body: "comment body", + IncludesCreatedEdit: false, + }, + }, + }, + }, + expectedOutput: "author:\ttest-author\nassociation:\tmember\nedited:\tfalse\nstatus:\tnone\n--\ncomment body\n--\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + rip := &RawIssuePrinter{ + IO: ios, + Comments: tc.comments, + } + + rip.Print(tc.presentationIssue, ghrepo.New("OWNER", "REPO")) + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + } +} diff --git a/pkg/cmd/issue/view/rich_issue_printer.go b/pkg/cmd/issue/view/rich_issue_printer.go new file mode 100644 index 00000000000..33f11a8571f --- /dev/null +++ b/pkg/cmd/issue/view/rich_issue_printer.go @@ -0,0 +1,145 @@ +package view + +import ( + "fmt" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" +) + +type RichIssuePrinter struct { + IO *iostreams.IOStreams + TimeNow time.Time + Comments bool +} + +func (p *RichIssuePrinter) Print(pi PresentationIssue, repo ghrepo.Interface) error { + // I think I'd like to make this easier to understand what the output should look like. + // That's probably doable by removing these helpers and just using a formatted string. + // I might experiment with that later. + + // header (Title and State) + p.header(pi, repo) + // Reactions + p.reactions(pi) + // Metadata + p.assigneeList(pi) + p.labelList(pi) + p.projectList(pi) + + p.milestone(pi) + + // Body + err := p.body(pi) + if err != nil { + return err + } + + // Comments + isCommentsPreview := !p.Comments + err = p.comments(pi, isCommentsPreview) + if err != nil { + return err + } + + // Footer + p.footer(pi) + + return nil +} + +func (p *RichIssuePrinter) header(pi PresentationIssue, repo ghrepo.Interface) { + fmt.Fprintf(p.IO.Out, "%s %s#%d\n", p.IO.ColorScheme().Bold(pi.Title), ghrepo.FullName(repo), pi.Number) + fmt.Fprintf(p.IO.Out, + "%s • %s opened %s • %s\n", + p.issueStateTitleWithColor(pi.State, pi.StateReason), + pi.Author, + text.FuzzyAgo(p.TimeNow, pi.CreatedAt), + text.Pluralize(pi.Comments.TotalCount, "comment"), + ) +} + +func (p *RichIssuePrinter) issueStateTitleWithColor(state string, stateReason string) string { + colorFunc := p.IO.ColorScheme().ColorFromString(prShared.ColorForIssueState(state, stateReason)) + formattedState := "Open" + if state == "CLOSED" { + formattedState = "Closed" + } + return colorFunc(formattedState) +} + +func (p *RichIssuePrinter) reactions(pi PresentationIssue) { + if pi.Reactions != "" { + fmt.Fprint(p.IO.Out, pi.Reactions) + fmt.Fprintln(p.IO.Out) + } +} + +func (p *RichIssuePrinter) assigneeList(pi PresentationIssue) { + assignees := pi.AssigneesList + if assignees != "" { + fmt.Fprint(p.IO.Out, p.IO.ColorScheme().Bold("Assignees: ")) + fmt.Fprintln(p.IO.Out, assignees) + } +} + +func (p *RichIssuePrinter) labelList(pi PresentationIssue) { + labels := pi.LabelsList + if labels != "" { + fmt.Fprint(p.IO.Out, p.IO.ColorScheme().Bold("Labels: ")) + fmt.Fprintln(p.IO.Out, labels) + } +} + +func (p *RichIssuePrinter) projectList(pi PresentationIssue) { + projects := pi.ProjectsList + if projects != "" { + fmt.Fprint(p.IO.Out, p.IO.ColorScheme().Bold("Projects: ")) + fmt.Fprintln(p.IO.Out, projects) + } +} + +func (p *RichIssuePrinter) milestone(pi PresentationIssue) { + if pi.MilestoneTitle != "" { + fmt.Fprint(p.IO.Out, p.IO.ColorScheme().Bold("Milestone: ")) + fmt.Fprintln(p.IO.Out, pi.MilestoneTitle) + } +} + +func (p *RichIssuePrinter) body(pi PresentationIssue) error { + var md string + var err error + body := pi.Body + if body == "" { + md = fmt.Sprintf("\n %s\n\n", p.IO.ColorScheme().Gray("No description provided")) + } else { + md, err = markdown.Render(body, + markdown.WithTheme(p.IO.TerminalTheme()), + markdown.WithWrap(p.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(p.IO.Out, "\n%s\n", md) + return nil +} + +func (p *RichIssuePrinter) comments(pi PresentationIssue, isPreview bool) error { + if pi.Comments.TotalCount > 0 { + comments, err := prShared.CommentList(p.IO, pi.Comments, api.PullRequestReviews{}, isPreview) + if err != nil { + return err + } + fmt.Fprint(p.IO.Out, comments) + } + return nil +} + +func (p *RichIssuePrinter) footer(pi PresentationIssue) { + fmt.Fprintf(p.IO.Out, p.IO.ColorScheme().Gray("View this issue on GitHub: %s\n"), pi.URL) +} diff --git a/pkg/cmd/issue/view/rich_issue_printer_test.go b/pkg/cmd/issue/view/rich_issue_printer_test.go new file mode 100644 index 00000000000..47aec7de40d --- /dev/null +++ b/pkg/cmd/issue/view/rich_issue_printer_test.go @@ -0,0 +1,405 @@ +package view + +import ( + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/magiconair/properties/assert" +) + +// This does not test color, just output +func Test_header(t *testing.T) { + tests := map[string]struct { + title string + number int + state string + stateReason string + createdAt string + author string + baseRepo ghrepo.Interface + expected string + }{ + "simple open issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "OPEN", + stateReason: "", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nOpen • monalisa opened about 1 day ago • 1 comment\n", + }, + "simple closed issue": { + title: "Simple Issue Test", + baseRepo: ghrepo.New("OWNER", "REPO"), + number: 123, + state: "CLOSED", + stateReason: "COMPLETED", + author: "monalisa", + createdAt: "2022-01-01T00:00:00Z", + expected: "Simple Issue Test OWNER/REPO#123\nClosed • monalisa opened about 1 day ago • 1 comment\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + createdAtTime, err := time.Parse(time.RFC3339, tc.createdAt) + if err != nil { + t.Fatal(err) + } + + presentationIssue := PresentationIssue{ + Title: tc.title, + Number: tc.number, + Comments: api.Comments{ + TotalCount: 1, + }, + State: tc.state, + StateReason: tc.stateReason, + CreatedAt: createdAtTime, + Author: tc.author, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + TimeNow: createdAtTime.AddDate(0, 0, 1), + } + richIssuePrinter.header(presentationIssue, tc.baseRepo) + assert.Equal(t, tc.expected, stdout.String()) + }) + } +} + +func Test_reactions(t *testing.T) { + tests := map[string]struct { + reactions string + expected string + }{ + "no reactions": { + reactions: "", + expected: "", + }, + "reactions": { + reactions: "a reaction", + expected: "a reaction\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + Reactions: tc.reactions, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + richIssuePrinter.reactions(presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_assigneeList(t *testing.T) { + tests := map[string]struct { + assignees string + expected string + }{ + "no assignees": { + assignees: "", + expected: "", + }, + "assignees": { + assignees: "monalisa, octocat", + expected: "Assignees: monalisa, octocat\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + AssigneesList: tc.assignees, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + richIssuePrinter.assigneeList(presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_labelList(t *testing.T) { + tests := map[string]struct { + labels string + expected string + }{ + "no labels": { + labels: "", + expected: "", + }, + "labels": { + labels: "bug, enhancement", + expected: "Labels: bug, enhancement\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + LabelsList: tc.labels, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + richIssuePrinter.labelList(presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_projectList(t *testing.T) { + tests := map[string]struct { + projectList string + expected string + }{ + "no projects": { + projectList: "", + expected: "", + }, + "some projects": { + projectList: "ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)", + expected: "Projects: ProjectV1 1 (Column 1), ProjectV1 2 (Awaiting triage)\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + ProjectsList: tc.projectList, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + richIssuePrinter.projectList(presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_milestone(t *testing.T) { + tests := map[string]struct { + milestone string + expected string + }{ + "no milestone": { + milestone: "", + expected: "", + }, + "milestone": { + milestone: "milestoneTitle", + expected: "Milestone: milestoneTitle\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + MilestoneTitle: tc.milestone, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + richIssuePrinter.milestone(presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} + +func Test_body(t *testing.T) { + tests := map[string]struct { + body string + expected string + }{ + "no body": { + body: "", + expected: "No description provided", + }, + "with body": { + body: "This is a body", + expected: "This is a body", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := PresentationIssue{ + Body: tc.body, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + err := richIssuePrinter.body(presentationIssue) + if err != nil { + t.Fatal(err) + } + // This is getting around whitespace issues I was having with assert.Equal + assert.Matches(t, stdout.String(), tc.expected) + }) + } +} + +func Test_comments(t *testing.T) { + test := map[string]struct { + commentNodes []api.Comment + expected string + isPreview bool + }{ + "no comments": { + commentNodes: []api.Comment{}, + expected: "", + isPreview: false, + }, + "comments": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", + isPreview: false, + }, + "preview": { + commentNodes: []api.Comment{ + { + Author: api.CommentAuthor{ + Login: "monalisa", + }, + Body: "body 1", + ReactionGroups: api.ReactionGroups{}, + }, + }, + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", + isPreview: true, + }, + } + for name, tc := range test { + t.Run(name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + timeNow, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + for i := range tc.commentNodes { + // subtract a day + tc.commentNodes[i].CreatedAt = timeNow.AddDate(0, 0, -1) + } + + presentationIssue := PresentationIssue{ + Comments: api.Comments{ + Nodes: tc.commentNodes, + TotalCount: len(tc.commentNodes), + }, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + TimeNow: timeNow, + } + + err = richIssuePrinter.comments(presentationIssue, tc.isPreview) + if err != nil { + t.Fatal(err) + } + + // I can't get these strings to match + // assert.Matches(t, stdout.String(), tc.expected) + }) + } +} + +func Test_footer(t *testing.T) { + tests := map[string]struct { + url string + expected string + }{ + "no url": { + url: "", + expected: "View this issue on GitHub: \n", + }, + "with url": { + url: "github.com/OWNER/REPO/issues/123", + expected: "View this issue on GitHub: github.com/OWNER/REPO/issues/123\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + presentationIssue := &PresentationIssue{ + URL: tc.url, + } + + richIssuePrinter := &RichIssuePrinter{ + IO: ios, + } + + richIssuePrinter.footer(*presentationIssue) + assert.Equal(t, stdout.String(), tc.expected) + }) + } +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 4ecae17ef3c..1382e4545dc 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" - prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" @@ -38,47 +37,7 @@ type ViewOptions struct { } type IssuePrinter interface { - Print(*PresentationIssue, ghrepo.Interface) error -} - -type RawIssuePrinter struct { - IO *iostreams.IOStreams - Comments bool -} - -func (p *RawIssuePrinter) Print(pi *PresentationIssue, repo ghrepo.Interface) error { - if p.Comments { - fmt.Fprint(p.IO.Out, prShared.RawCommentList(pi.Comments, api.PullRequestReviews{})) - return nil - } - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(p.IO.Out, "title:\t%s\n", pi.Title) - fmt.Fprintf(p.IO.Out, "state:\t%s\n", pi.State) - fmt.Fprintf(p.IO.Out, "author:\t%s\n", pi.Author) - fmt.Fprintf(p.IO.Out, "labels:\t%s\n", pi.LabelsList) - fmt.Fprintf(p.IO.Out, "comments:\t%d\n", pi.Comments.TotalCount) - fmt.Fprintf(p.IO.Out, "assignees:\t%s\n", pi.AssigneesList) - fmt.Fprintf(p.IO.Out, "projects:\t%s\n", pi.ProjectsList) - fmt.Fprintf(p.IO.Out, "milestone:\t%s\n", pi.MilestoneTitle) - fmt.Fprintf(p.IO.Out, "number:\t%d\n", pi.Number) - fmt.Fprintln(p.IO.Out, "--") - fmt.Fprintln(p.IO.Out, pi.Body) - - return nil -} - -type RichIssuePrinter struct { - IO *iostreams.IOStreams - TimeNow time.Time - Comments bool -} - -func (p *RichIssuePrinter) Print(issue *PresentationIssue, repo ghrepo.Interface) error { - ipf := NewIssuePrintFormatter(issue, p.IO, p.TimeNow, repo) - isCommentsPreview := !p.Comments - return ipf.renderHumanIssuePreview(isCommentsPreview) + Print(PresentationIssue, ghrepo.Interface) error } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -190,7 +149,7 @@ func viewRun(opts *ViewOptions) error { issue.Labels.SortAlphabeticallyIgnoreCase() - presentationIssue, err := apiIssueToPresentationIssue(issue, opts.IO.ColorScheme()) + presentationIssue, err := MapApiIssueToPresentationIssue(issue, opts.IO.ColorScheme()) if err != nil { return err } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index f20539920bf..5df91d5abeb 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/featuredetection" @@ -498,63 +497,3 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } - -func TestRawIssuePrinting(t *testing.T) { - tests := map[string]struct { - comments bool - presentationIssue *PresentationIssue - expectedOutput string - }{ - "basic issue, no comments requested": { - comments: false, - presentationIssue: &PresentationIssue{ - Title: "issueTitle", - State: "issueState", - Author: "authorLogin", - LabelsList: "labelsList", - Comments: api.Comments{ - TotalCount: 1, - }, - AssigneesList: "assigneesList", - ProjectsList: "projectsList", - MilestoneTitle: "milestoneTitle", - Number: 123, - Body: "issueBody", - }, - expectedOutput: "title:\tissueTitle\nstate:\tissueState\nauthor:\tauthorLogin\nlabels:\tlabelsList\ncomments:\t1\nassignees:\tassigneesList\nprojects:\tprojectsList\nmilestone:\tmilestoneTitle\nnumber:\t123\n--\nissueBody\n", - }, - "basic issue, displays only comments when requested": { - comments: true, - presentationIssue: &PresentationIssue{ - Comments: api.Comments{ - TotalCount: 1, - Nodes: []api.Comment{ - { - Author: api.CommentAuthor{ - Login: "test-author", - }, - AuthorAssociation: "member", - Body: "comment body", - IncludesCreatedEdit: false, - }, - }, - }, - }, - expectedOutput: "author:\ttest-author\nassociation:\tmember\nedited:\tfalse\nstatus:\tnone\n--\ncomment body\n--\n", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - - rip := &RawIssuePrinter{ - IO: ios, - Comments: tc.comments, - } - - rip.Print(tc.presentationIssue, ghrepo.New("OWNER", "REPO")) - assert.Equal(t, tc.expectedOutput, stdout.String()) - }) - } -} From 7a5236bf763c0d95424a37e2d1ff305a241395ef Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 29 Aug 2024 14:10:19 -0700 Subject: [PATCH 14/21] Add some comments for thoughts post cleanup --- pkg/cmd/issue/view/rich_issue_printer_test.go | 7 +++++++ pkg/cmd/issue/view/view.go | 1 + 2 files changed, 8 insertions(+) diff --git a/pkg/cmd/issue/view/rich_issue_printer_test.go b/pkg/cmd/issue/view/rich_issue_printer_test.go index 47aec7de40d..e5b4e69fa25 100644 --- a/pkg/cmd/issue/view/rich_issue_printer_test.go +++ b/pkg/cmd/issue/view/rich_issue_printer_test.go @@ -10,6 +10,13 @@ import ( "github.com/magiconair/properties/assert" ) +// Should we write an integration test for Print? I'm not sure how easy +// this is or how much value it will add... +// +// func Test_Print(t *testing.T) { +// ...test logic... +// } + // This does not test color, just output func Test_header(t *testing.T) { tests := map[string]struct { diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 1382e4545dc..88070a58f13 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -147,6 +147,7 @@ func viewRun(opts *ViewOptions) error { return opts.Exporter.Write(opts.IO, issue) } + // Todo: remove this from the api package and move to the presentationIssue issue.Labels.SortAlphabeticallyIgnoreCase() presentationIssue, err := MapApiIssueToPresentationIssue(issue, opts.IO.ColorScheme()) From fabcf522f3074abe3f60af7bc5ec548a8dd32df7 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 09:49:11 -0700 Subject: [PATCH 15/21] Move issue label sorting to the presentationIssue It doesn't make sense that the sorting of labels lives on the api when it is the presentationIssue that cares about sorting. Thus, I've moved the sorting functionality to the presentationIssue --- api/queries_issue_test.go | 61 ------------------- pkg/cmd/issue/view/presentation_issue.go | 10 ++- pkg/cmd/issue/view/presentation_issue_test.go | 53 ++++++++++++++++ pkg/cmd/issue/view/view.go | 3 - 4 files changed, 62 insertions(+), 65 deletions(-) delete mode 100644 api/queries_issue_test.go diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go deleted file mode 100644 index 0221fb9adbd..00000000000 --- a/api/queries_issue_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package api - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_Labels_SortAlphabeticallyIgnoreCase(t *testing.T) { - tests := map[string]struct { - labels Labels - expected Labels - }{ - "no repeat labels": { - labels: Labels{ - Nodes: []IssueLabel{ - {Name: "c"}, - {Name: "B"}, - {Name: "a"}, - }, - }, - expected: Labels{ - Nodes: []IssueLabel{ - {Name: "a"}, - {Name: "B"}, - {Name: "c"}, - }, - }, - }, - "repeat labels case insensitive": { - labels: Labels{ - Nodes: []IssueLabel{ - {Name: "c"}, - {Name: "B"}, - {Name: "C"}, - }, - }, - expected: Labels{ - Nodes: []IssueLabel{ - {Name: "B"}, - {Name: "c"}, - {Name: "C"}, - }, - }, - }, - "no labels": { - labels: Labels{ - Nodes: []IssueLabel{}, - }, - expected: Labels{ - Nodes: []IssueLabel{}, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - tc.labels.SortAlphabeticallyIgnoreCase() - assert.Equal(t, tc.expected, tc.labels) - }) - } -} diff --git a/pkg/cmd/issue/view/presentation_issue.go b/pkg/cmd/issue/view/presentation_issue.go index 5b4e8c75da6..eca7c01e7af 100644 --- a/pkg/cmd/issue/view/presentation_issue.go +++ b/pkg/cmd/issue/view/presentation_issue.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "slices" "strings" "time" @@ -38,7 +39,7 @@ func MapApiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.Col StateReason: issue.StateReason, Reactions: prShared.ReactionGroupList(issue.ReactionGroups), AssigneesList: getAssigneeListString(issue.Assignees), - LabelsList: getColorizedLabelsList(issue.Labels, colorScheme), + LabelsList: getColorizedLabelsList(sortAlphabeticallyIgnoreCase(issue.Labels), colorScheme), ProjectsList: getProjectListString(issue.ProjectCards, issue.ProjectItems), Body: issue.Body, URL: issue.URL, @@ -109,3 +110,10 @@ func getColorizedLabelsList(issueLabels api.Labels, colorScheme *iostreams.Color return strings.Join(labelNames, ", ") } + +func sortAlphabeticallyIgnoreCase(l api.Labels) api.Labels { + slices.SortStableFunc(l.Nodes, func(a, b api.IssueLabel) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return l +} diff --git a/pkg/cmd/issue/view/presentation_issue_test.go b/pkg/cmd/issue/view/presentation_issue_test.go index d9f20c2fcd8..a72d3cf51e8 100644 --- a/pkg/cmd/issue/view/presentation_issue_test.go +++ b/pkg/cmd/issue/view/presentation_issue_test.go @@ -343,3 +343,56 @@ func Test_getProjectListString(t *testing.T) { }) } } + +func Test_sortAlphabeticallyIgnoreCase(t *testing.T) { + tests := map[string]struct { + labels api.Labels + expected api.Labels + }{ + "no repeat labels": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + {Name: "c"}, + {Name: "B"}, + {Name: "a"}, + }, + }, + expected: api.Labels{ + Nodes: []api.IssueLabel{ + {Name: "a"}, + {Name: "B"}, + {Name: "c"}, + }, + }, + }, + "repeat labels case insensitive": { + labels: api.Labels{ + Nodes: []api.IssueLabel{ + {Name: "c"}, + {Name: "B"}, + {Name: "C"}, + }, + }, + expected: api.Labels{ + Nodes: []api.IssueLabel{ + {Name: "B"}, + {Name: "c"}, + {Name: "C"}, + }, + }, + }, + "no labels": { + labels: api.Labels{ + Nodes: []api.IssueLabel{}, + }, + expected: api.Labels{ + Nodes: []api.IssueLabel{}, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, sortAlphabeticallyIgnoreCase(tc.labels)) + }) + } +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 88070a58f13..cd9c7a25874 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -147,9 +147,6 @@ func viewRun(opts *ViewOptions) error { return opts.Exporter.Write(opts.IO, issue) } - // Todo: remove this from the api package and move to the presentationIssue - issue.Labels.SortAlphabeticallyIgnoreCase() - presentationIssue, err := MapApiIssueToPresentationIssue(issue, opts.IO.ColorScheme()) if err != nil { return err From 56da351b62465e61eb0ad164abe4e6bf6af79c25 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 10:00:20 -0700 Subject: [PATCH 16/21] Clean up comments and unused code --- api/queries_comments.go | 4 +- .../issueView_v1ProjectsDisabled.json | 25 ------------ .../fixtures/issueView_v1ProjectsEnabled.json | 39 ------------------- pkg/cmd/issue/view/presentation_issue.go | 3 ++ pkg/cmd/issue/view/raw_issue_printer.go | 1 + pkg/cmd/issue/view/rich_issue_printer.go | 1 + pkg/cmd/pr/shared/comments.go | 9 ----- 7 files changed, 7 insertions(+), 75 deletions(-) delete mode 100644 pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json delete mode 100644 pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json diff --git a/api/queries_comments.go b/api/queries_comments.go index 69bb189ab73..db759b88941 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -139,8 +139,8 @@ func (c Comment) Reactions() ReactionGroups { return c.ReactionGroups } -// TODO: What is this supposed to do, and how does it relate to -// formatting comments for PR or Issue view? +// I think that this functionality was moved elsewhere and this has been +// added so the interface doesn't break func (c Comment) Status() string { return "" } diff --git a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json deleted file mode 100644 index e176b1be2ad..00000000000 --- a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsDisabled.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "number": 123, - "body": "**bold story**", - "title": "ix of coins", - "state": "OPEN", - "createdAt": "2011-01-26T19:01:12Z", - "author": { - "login": "marseilles" - }, - "assignees": { - "nodes": [], - "totalCount": 0 - }, - "labels": { - "nodes": [], - "totalCount": 0 - }, - "milestone": { - "title": "" - }, - "comments": { - "totalCount": 9 - }, - "url": "https://github.com/OWNER/REPO/issues/123" -} diff --git a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json b/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json deleted file mode 100644 index 41e82dd2738..00000000000 --- a/pkg/cmd/issue/view/fixtures/issueView_v1ProjectsEnabled.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "number": 123, - "body": "**bold story**", - "title": "ix of coins", - "state": "OPEN", - "createdAt": "2011-01-26T19:01:12Z", - "author": { - "login": "marseilles" - }, - "assignees": { - "nodes": [], - "totalCount": 0 - }, - "labels": { - "nodes": [], - "totalCount": 0 - }, - "projectcards": { - "nodes": [ - { - "project": { - "name": "Project 1" - }, - "column": { - "name": "Column name" - } - } - ], - "totalCount": 1 - }, - "milestone": { - "title": "" - }, - "comments": { - "totalCount": 9 - }, - "url": "https://github.com/OWNER/REPO/issues/123" -} - diff --git a/pkg/cmd/issue/view/presentation_issue.go b/pkg/cmd/issue/view/presentation_issue.go index eca7c01e7af..e2b16f1b1f0 100644 --- a/pkg/cmd/issue/view/presentation_issue.go +++ b/pkg/cmd/issue/view/presentation_issue.go @@ -28,6 +28,9 @@ type PresentationIssue struct { URL string } +// Creates a new PresentationIssue from an api.Issue. +// The PresentationIssue is what the issue printers need to display an +// issue to the user. func MapApiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.ColorScheme) (PresentationIssue, error) { presentationIssue := PresentationIssue{ Title: issue.Title, diff --git a/pkg/cmd/issue/view/raw_issue_printer.go b/pkg/cmd/issue/view/raw_issue_printer.go index 870bc798fd1..8f0d6610246 100644 --- a/pkg/cmd/issue/view/raw_issue_printer.go +++ b/pkg/cmd/issue/view/raw_issue_printer.go @@ -14,6 +14,7 @@ type RawIssuePrinter struct { Comments bool } +// Print outputs the issue to the terminal for non-TTY use cases. func (p *RawIssuePrinter) Print(pi PresentationIssue, repo ghrepo.Interface) error { if p.Comments { fmt.Fprint(p.IO.Out, prShared.RawCommentList(pi.Comments, api.PullRequestReviews{})) diff --git a/pkg/cmd/issue/view/rich_issue_printer.go b/pkg/cmd/issue/view/rich_issue_printer.go index 33f11a8571f..6b2f0f5d9f0 100644 --- a/pkg/cmd/issue/view/rich_issue_printer.go +++ b/pkg/cmd/issue/view/rich_issue_printer.go @@ -18,6 +18,7 @@ type RichIssuePrinter struct { Comments bool } +// Print outputs an issue to the terminal for TTY use cases. func (p *RichIssuePrinter) Print(pi PresentationIssue, repo ghrepo.Interface) error { // I think I'd like to make this easier to understand what the output should look like. // That's probably doable by removing these helpers and just using a formatted string. diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index 601bc711c41..a05108d7ba1 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -12,7 +12,6 @@ import ( "github.com/cli/cli/v2/pkg/markdown" ) -// TODO: can this interface be removed? type Comment interface { Identifier() string AuthorLogin() string @@ -27,14 +26,6 @@ type Comment interface { Status() string } -// type PresentationComment struct { -// AuthorLogin string -// Assocation string -// IsEdited bool -// Status string ???? -// Content string -// } - func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string { sortedComments := sortComments(comments, reviews) var b strings.Builder From edfc87314131c96d49c1b9532ad44b49e27a7b70 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 10:09:29 -0700 Subject: [PATCH 17/21] Use correct assert in presentation issue tests --- pkg/cmd/issue/view/presentation_issue_test.go | 2 +- pkg/cmd/issue/view/raw_issue_printer_test.go | 2 +- pkg/cmd/issue/view/rich_issue_printer_test.go | 11 ++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/issue/view/presentation_issue_test.go b/pkg/cmd/issue/view/presentation_issue_test.go index a72d3cf51e8..83d66a5e267 100644 --- a/pkg/cmd/issue/view/presentation_issue_test.go +++ b/pkg/cmd/issue/view/presentation_issue_test.go @@ -6,7 +6,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/magiconair/properties/assert" + "github.com/stretchr/testify/assert" ) func Test_MapApiIssueToPresentationIssue(t *testing.T) { diff --git a/pkg/cmd/issue/view/raw_issue_printer_test.go b/pkg/cmd/issue/view/raw_issue_printer_test.go index 10ab560b336..b0d3465cb7b 100644 --- a/pkg/cmd/issue/view/raw_issue_printer_test.go +++ b/pkg/cmd/issue/view/raw_issue_printer_test.go @@ -6,7 +6,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/magiconair/properties/assert" + "github.com/stretchr/testify/assert" ) func TestRawIssuePrinting(t *testing.T) { diff --git a/pkg/cmd/issue/view/rich_issue_printer_test.go b/pkg/cmd/issue/view/rich_issue_printer_test.go index e5b4e69fa25..ad557a5271e 100644 --- a/pkg/cmd/issue/view/rich_issue_printer_test.go +++ b/pkg/cmd/issue/view/rich_issue_printer_test.go @@ -7,16 +7,9 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/magiconair/properties/assert" + "github.com/stretchr/testify/assert" ) -// Should we write an integration test for Print? I'm not sure how easy -// this is or how much value it will add... -// -// func Test_Print(t *testing.T) { -// ...test logic... -// } - // This does not test color, just output func Test_header(t *testing.T) { tests := map[string]struct { @@ -293,7 +286,7 @@ func Test_body(t *testing.T) { t.Fatal(err) } // This is getting around whitespace issues I was having with assert.Equal - assert.Matches(t, stdout.String(), tc.expected) + assert.Contains(t, stdout.String(), tc.expected) }) } } From 53c93c59dec0f0bf70577d3833a289692d95992d Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 09:49:11 -0700 Subject: [PATCH 18/21] Move issue label sorting to the presentationIssue It doesn't make sense that the sorting of labels lives on the api when it is the presentationIssue that cares about sorting. Thus, I've moved the sorting functionality to the presentationIssue --- api/queries_issue.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index eac8f8af953..813dab4961f 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -3,8 +3,6 @@ package api import ( "encoding/json" "fmt" - "slices" - "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" @@ -84,12 +82,6 @@ func (l Labels) Names() []string { return names } -func (l Labels) SortAlphabeticallyIgnoreCase() { - slices.SortStableFunc(l.Nodes, func(a, b IssueLabel) int { - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - }) -} - type ProjectCards struct { Nodes []*ProjectInfo TotalCount int From c034ec9fae89045cc59abbf69ea91fe272c4e68c Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 16:02:46 -0700 Subject: [PATCH 19/21] Clean up comments There were several comments that were addressed in the PR that I was able to clean up. Additionally, an old commented-out test was fixed. --- api/queries_comments.go | 2 -- pkg/cmd/issue/view/presentation_issue_test.go | 4 ++-- pkg/cmd/issue/view/rich_issue_printer_test.go | 7 +++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index db759b88941..5cc84a3e43f 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -139,8 +139,6 @@ func (c Comment) Reactions() ReactionGroups { return c.ReactionGroups } -// I think that this functionality was moved elsewhere and this has been -// added so the interface doesn't break func (c Comment) Status() string { return "" } diff --git a/pkg/cmd/issue/view/presentation_issue_test.go b/pkg/cmd/issue/view/presentation_issue_test.go index 83d66a5e267..32564119d6a 100644 --- a/pkg/cmd/issue/view/presentation_issue_test.go +++ b/pkg/cmd/issue/view/presentation_issue_test.go @@ -139,7 +139,7 @@ func Test_MapApiIssueToPresentationIssue(t *testing.T) { t.Fatal(err) } - // These are here for development purposes + // These are here to aid development and debugging of the individual pieces assert.Equal(t, presentationIssue.Title, tc.expect.Title) assert.Equal(t, presentationIssue.Number, tc.expect.Number) assert.Equal(t, presentationIssue.CreatedAt, tc.expect.CreatedAt) @@ -154,7 +154,7 @@ func Test_MapApiIssueToPresentationIssue(t *testing.T) { assert.Equal(t, presentationIssue.Body, tc.expect.Body) assert.Equal(t, presentationIssue.URL, tc.expect.URL) - // This is the actual test + // This tests the entire struct assert.Equal(t, presentationIssue, tc.expect) }) } diff --git a/pkg/cmd/issue/view/rich_issue_printer_test.go b/pkg/cmd/issue/view/rich_issue_printer_test.go index ad557a5271e..68640106fbb 100644 --- a/pkg/cmd/issue/view/rich_issue_printer_test.go +++ b/pkg/cmd/issue/view/rich_issue_printer_test.go @@ -312,7 +312,7 @@ func Test_comments(t *testing.T) { ReactionGroups: api.ReactionGroups{}, }, }, - expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1\n", + expected: "monalisa () • Dec 31, 2021 • Newest comment\n\n body 1", isPreview: false, }, "preview": { @@ -331,7 +331,7 @@ func Test_comments(t *testing.T) { } for name, tc := range test { t.Run(name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() + ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) @@ -363,8 +363,7 @@ func Test_comments(t *testing.T) { t.Fatal(err) } - // I can't get these strings to match - // assert.Matches(t, stdout.String(), tc.expected) + assert.Contains(t, stdout.String(), tc.expected) }) } } From 12af5e7be51471f8b889094b229b6c6dd454ee88 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 16:07:21 -0700 Subject: [PATCH 20/21] Rename map functions for PresentationIssue The pattern getListString caused some confusion in the PR about what the function was doing. I've renamed the helper functions to stringify to clear up that confusion. --- pkg/cmd/issue/view/presentation_issue.go | 12 ++++++------ pkg/cmd/issue/view/presentation_issue_test.go | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/issue/view/presentation_issue.go b/pkg/cmd/issue/view/presentation_issue.go index e2b16f1b1f0..e7147a27c74 100644 --- a/pkg/cmd/issue/view/presentation_issue.go +++ b/pkg/cmd/issue/view/presentation_issue.go @@ -41,9 +41,9 @@ func MapApiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.Col State: issue.State, StateReason: issue.StateReason, Reactions: prShared.ReactionGroupList(issue.ReactionGroups), - AssigneesList: getAssigneeListString(issue.Assignees), - LabelsList: getColorizedLabelsList(sortAlphabeticallyIgnoreCase(issue.Labels), colorScheme), - ProjectsList: getProjectListString(issue.ProjectCards, issue.ProjectItems), + AssigneesList: stringifyAssignees(issue.Assignees), + LabelsList: stringifyAndColorizeLabels(sortAlphabeticallyIgnoreCase(issue.Labels), colorScheme), + ProjectsList: stringifyProjects(issue.ProjectCards, issue.ProjectItems), Body: issue.Body, URL: issue.URL, } @@ -55,7 +55,7 @@ func MapApiIssueToPresentationIssue(issue *api.Issue, colorScheme *iostreams.Col return presentationIssue, nil } -func getProjectListString(projectCards api.ProjectCards, projectItems api.ProjectItems) string { +func stringifyProjects(projectCards api.ProjectCards, projectItems api.ProjectItems) string { if len(projectCards.Nodes) == 0 && len(projectItems.Nodes) == 0 { return "" } @@ -84,7 +84,7 @@ func getProjectListString(projectCards api.ProjectCards, projectItems api.Projec return list } -func getAssigneeListString(issueAssignees api.Assignees) string { +func stringifyAssignees(issueAssignees api.Assignees) string { if len(issueAssignees.Nodes) == 0 { return "" } @@ -101,7 +101,7 @@ func getAssigneeListString(issueAssignees api.Assignees) string { return list } -func getColorizedLabelsList(issueLabels api.Labels, colorScheme *iostreams.ColorScheme) string { +func stringifyAndColorizeLabels(issueLabels api.Labels, colorScheme *iostreams.ColorScheme) string { labelNames := make([]string, len(issueLabels.Nodes)) for j, label := range issueLabels.Nodes { if colorScheme == nil { diff --git a/pkg/cmd/issue/view/presentation_issue_test.go b/pkg/cmd/issue/view/presentation_issue_test.go index 32564119d6a..46d7bad470d 100644 --- a/pkg/cmd/issue/view/presentation_issue_test.go +++ b/pkg/cmd/issue/view/presentation_issue_test.go @@ -160,7 +160,7 @@ func Test_MapApiIssueToPresentationIssue(t *testing.T) { } } -func Test_getAssigneeListString(t *testing.T) { +func Test_stringifyAssignees(t *testing.T) { tests := map[string]struct { assignees api.Assignees expected string @@ -183,12 +183,12 @@ func Test_getAssigneeListString(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.expected, getAssigneeListString(tc.assignees)) + assert.Equal(t, tc.expected, stringifyAssignees(tc.assignees)) }) } } -func Test_getColorizedLabelsList(t *testing.T) { +func Test_stringifyAndColorizeLabels(t *testing.T) { tests := map[string]struct { labels api.Labels isColorSchemeEnabled bool @@ -231,12 +231,12 @@ func Test_getColorizedLabelsList(t *testing.T) { ios.SetStderrTTY(true) ios.SetColorEnabled(tc.isColorSchemeEnabled) - assert.Equal(t, getColorizedLabelsList(tc.labels, ios.ColorScheme()), tc.expected) + assert.Equal(t, stringifyAndColorizeLabels(tc.labels, ios.ColorScheme()), tc.expected) }) } } -func Test_getProjectListString(t *testing.T) { +func Test_stringifyProjects(t *testing.T) { tests := map[string]struct { projectCards api.ProjectCards projectItems api.ProjectItems @@ -339,7 +339,7 @@ func Test_getProjectListString(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - assert.Equal(t, getProjectListString(tc.projectCards, tc.projectItems), tc.expected) + assert.Equal(t, stringifyProjects(tc.projectCards, tc.projectItems), tc.expected) }) } } From f6a677e770952edc7677f0496654b7cac6e88e6b Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 11 Sep 2024 16:02:46 -0700 Subject: [PATCH 21/21] Clean up comments There were several comments that were addressed in the PR that I was able to clean up. Additionally, an old commented-out test was fixed. --- pkg/cmd/issue/view/rich_issue_printer.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/issue/view/rich_issue_printer.go b/pkg/cmd/issue/view/rich_issue_printer.go index 6b2f0f5d9f0..1b6827f269a 100644 --- a/pkg/cmd/issue/view/rich_issue_printer.go +++ b/pkg/cmd/issue/view/rich_issue_printer.go @@ -20,9 +20,6 @@ type RichIssuePrinter struct { // Print outputs an issue to the terminal for TTY use cases. func (p *RichIssuePrinter) Print(pi PresentationIssue, repo ghrepo.Interface) error { - // I think I'd like to make this easier to understand what the output should look like. - // That's probably doable by removing these helpers and just using a formatted string. - // I might experiment with that later. // header (Title and State) p.header(pi, repo)