From c8dd61d837d8e948cbeddab53b995d3b653b95ea Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 17:06:35 +0200 Subject: [PATCH 1/4] Feature detect v1 projects on non-interactive issue create --- api/queries_repo.go | 90 +++++++++++++++++++++++------ api/queries_repo_test.go | 3 +- pkg/cmd/issue/create/create.go | 27 ++++++--- pkg/cmd/issue/create/create_test.go | 78 +++++++++++++++++++++++++ pkg/cmd/pr/create/create.go | 16 +++-- pkg/cmd/pr/shared/editable.go | 3 +- pkg/cmd/pr/shared/params.go | 13 +++-- pkg/cmd/pr/shared/survey.go | 5 +- pkg/cmd/pr/shared/survey_test.go | 65 ++++++++++++++++++++- 9 files changed, 258 insertions(+), 42 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 53e6d879a..7c33825b0 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -863,7 +863,8 @@ type RepoMetadataInput struct { Assignees bool Reviewers bool Labels bool - Projects bool + ProjectsV1 bool + ProjectsV2 bool Milestones bool } @@ -882,6 +883,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } + if input.Reviewers { g.Go(func() error { teams, err := OrganizationTeams(client, repo) @@ -894,6 +896,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return nil }) } + if input.Reviewers { g.Go(func() error { login, err := CurrentLoginName(client, repo.RepoHost()) @@ -904,6 +907,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } + if input.Labels { g.Go(func() error { labels, err := RepoLabels(client, repo) @@ -914,13 +918,23 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } - if input.Projects { + + if input.ProjectsV1 { g.Go(func() error { var err error - result.Projects, result.ProjectsV2, err = relevantProjects(client, repo) + result.Projects, err = v1Projects(client, repo) return err }) } + + if input.ProjectsV2 { + g.Go(func() error { + var err error + result.ProjectsV2, err = v2Projects(client, repo) + return err + }) + } + if input.Milestones { g.Go(func() error { milestones, err := RepoMilestones(client, repo, "open") @@ -943,7 +957,8 @@ type RepoResolveInput struct { Assignees []string Reviewers []string Labels []string - Projects []string + ProjectsV1 bool + ProjectsV2 bool Milestones []string } @@ -970,7 +985,8 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes // there is no way to look up projects nor milestones by name, so preload them all mi := RepoMetadataInput{ - Projects: len(input.Projects) > 0, + ProjectsV1: input.ProjectsV1, + ProjectsV2: input.ProjectsV2, Milestones: len(input.Milestones) > 0, } result, err := RepoMetadata(client, repo, mi) @@ -1245,18 +1261,12 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s return ProjectsToPaths(projects, projectsV2, projectNames) } -// RelevantProjects retrieves set of Projects and ProjectsV2 relevant to given repository: +// v1Projects retrieves set of RepoProjects relevant to given repository: // - Projects for repository // - Projects for repository organization, if it belongs to one -// - 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 v1Projects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { var repoProjects []RepoProject var orgProjects []RepoProject - var userProjectsV2 []ProjectV2 - var repoProjectsV2 []ProjectV2 - var orgProjectsV2 []ProjectV2 g, _ := errgroup.WithContext(context.Background()) @@ -1268,6 +1278,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return err }) + g.Go(func() error { var err error orgProjects, err = OrganizationProjects(client, repo) @@ -1277,6 +1288,29 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + + if err := g.Wait(); err != nil { + return nil, err + } + + projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects)) + projects = append(projects, repoProjects...) + projects = append(projects, orgProjects...) + + return projects, nil +} + +// v2Projects retrieves set of ProjectV2 relevant to given repository: +// - ProjectsV2 owned by current user +// - ProjectsV2 linked to repository +// - ProjectsV2 owned by repository organization, if it belongs to one +func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { + var userProjectsV2 []ProjectV2 + var repoProjectsV2 []ProjectV2 + var orgProjectsV2 []ProjectV2 + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { var err error userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost()) @@ -1286,6 +1320,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + g.Go(func() error { var err error repoProjectsV2, err = RepoProjectsV2(client, repo) @@ -1295,6 +1330,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + g.Go(func() error { var err error orgProjectsV2, err = OrganizationProjectsV2(client, repo) @@ -1308,13 +1344,9 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P }) if err := g.Wait(); err != nil { - return nil, nil, err + return nil, err } - projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects)) - projects = append(projects, repoProjects...) - projects = append(projects, orgProjects...) - // ProjectV2 might appear across multiple queries so use a map to keep them deduplicated. m := make(map[string]ProjectV2, len(userProjectsV2)+len(repoProjectsV2)+len(orgProjectsV2)) for _, p := range userProjectsV2 { @@ -1331,7 +1363,27 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P projectsV2 = append(projectsV2, p) } - return projects, projectsV2, nil + return projectsV2, nil +} + +// relevantProjects retrieves set of Project or ProjectV2 relevant to given repository: +// - Projects for repository +// - Projects for repository organization, if it belongs to one +// - 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) { + v1Projects, err := v1Projects(client, repo) + if err != nil { + return nil, nil, err + } + + v2Projects, err := v2Projects(client, repo) + if err != nil { + return nil, nil, err + } + + return v1Projects, v2Projects, nil } func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 13aee459a..5f5f55b67 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -44,7 +44,8 @@ func Test_RepoMetadata(t *testing.T) { Assignees: true, Reviewers: true, Labels: true, - Projects: true, + ProjectsV1: true, + ProjectsV2: true, Milestones: true, } diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 2e3e0de51..0976fd94f 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" @@ -24,6 +26,7 @@ type CreateOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser Prompter prShared.Prompt + Detector fd.Detector TitledEditSurvey func(string, string) (string, string, error) RootDirOverride string @@ -46,11 +49,12 @@ type CreateOptions struct { func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Browser: f.Browser, - Prompter: f.Prompter, + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Browser: f.Browser, + Prompter: f.Prompter, + TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}), } @@ -146,6 +150,15 @@ func createRun(opts *CreateOptions) (err error) { return } + // TODO projectsV1Deprecation + // Remove this section as we should no longer need to detect + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + isTerminal := opts.IO.IsStdoutTTY() var milestones []string @@ -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, projectsV1Support) 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, projectsV1Support) if err != nil { return } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 8e49700a0..d8c1c5d92 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" + 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/prompter" @@ -521,6 +522,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { opts.RootDirOverride = rootDir + opts.Detector = &fd.EnabledDetectorMock{} return createRun(opts) }) @@ -1026,3 +1028,79 @@ func TestIssueCreate_projectsV2(t *testing.T) { assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + + t.Run("non-interactive submission", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.EnabledDetectorMock{}, + Title: "Test Title", + Body: "Test Body", + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.DisabledDetectorMock{}, + Title: "Test Title", + Body: "Test Body", + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) +} diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index eda7a3ce7..27c929dc4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -440,7 +440,8 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } if opts.RecoverFile != "" { @@ -536,7 +537,8 @@ func createRun(opts *CreateOptions) error { Repo: ctx.PRRefs.BaseRepo(), State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state) + // TODO wm: revisit project support + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported) if err != nil { return err } @@ -565,11 +567,13 @@ func createRun(opts *CreateOptions) error { if action == shared.SubmitDraftAction { state.Draft = true - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } if action == shared.SubmitAction { - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } err = errors.New("expected to cancel, preview, or submit") @@ -966,7 +970,7 @@ func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) { return remotes, nil } -func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error { +func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState, projectV1Support gh.ProjectsV1Support) error { client := ctx.Client params := map[string]interface{}{ @@ -982,7 +986,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.PRRefs.BaseRepo(), params, &state) + err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state, projectV1Support) if err != nil { return err } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index cec3bfe8c..0bebb999a 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -381,7 +381,8 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) Reviewers: editable.Reviewers.Edited, Assignees: editable.Assignees.Edited, Labels: editable.Labels.Edited, - Projects: editable.Projects.Edited, + ProjectsV1: editable.Projects.Edited, + ProjectsV2: editable.Projects.Edited, Milestones: editable.Milestone.Edited, } metadata, err := api.RepoMetadata(client, repo, input) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 128c51068..9688a2aca 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -6,6 +6,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/search" "github.com/google/shlex" @@ -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) error { +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error { resolveInput := api.RepoResolveInput{} if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { @@ -72,7 +73,11 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada } if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { - resolveInput.Projects = tb.Projects + if projectV1Support == gh.ProjectsV1Supported { + resolveInput.ProjectsV1 = true + } + + resolveInput.ProjectsV2 = true } if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) { @@ -93,12 +98,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, projectV1Support gh.ProjectsV1Support) error { if !tb.HasMetadata() { return nil } - if err := fillMetadata(client, baseRepo, tb); err != nil { + if err := fillMetadata(client, baseRepo, tb, projectV1Support); err != nil { return err } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index ce38535d9..23079a391 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -151,7 +151,7 @@ type RepoMetadataFetcher interface { RepoMetadataFetch(api.RepoMetadataInput) (*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, projectsV1Support gh.ProjectsV1Support) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -181,7 +181,8 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface Reviewers: isChosen("Reviewers"), Assignees: isChosen("Assignees"), Labels: isChosen("Labels"), - Projects: isChosen("Projects"), + ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported, + ProjectsV2: isChosen("Projects"), Milestones: isChosen("Milestone"), } metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index d74696460..c094b7556 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -1,13 +1,16 @@ package shared import ( + "errors" "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" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type metadataFetcher struct { @@ -68,7 +71,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, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -113,7 +116,8 @@ 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, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -124,6 +128,63 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { assert.Equal(t, []string{"The road to 1.0"}, state.Projects) } +// TODO projectsV1Deprecation +// Remove this test and projectsV1MetadataFetcherSpy +func TestMetadataSurveyProjectV1Deprecation(t *testing.T) { + t.Run("when projectsV1 is supported, requests projectsV1", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &projectsV1MetadataFetcherSpy{} + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) { + i, err := prompter.IndexFor(options, "Projects") + require.NoError(t, err) + return []int{i}, nil + }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported) + require.ErrorContains(t, err, "expected test error") + + require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested") + }) + + t.Run("when projectsV1 is supported, does not request projectsV1", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &projectsV1MetadataFetcherSpy{} + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) { + i, err := prompter.IndexFor(options, "Projects") + require.NoError(t, err) + return []int{i}, nil + }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported) + require.ErrorContains(t, err, "expected test error") + + require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested") + }) +} + +type projectsV1MetadataFetcherSpy struct { + projectsV1Requested bool +} + +func (mf *projectsV1MetadataFetcherSpy) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + if input.ProjectsV1 { + mf.projectsV1Requested = true + } + return nil, errors.New("expected test error") +} + func TestTitledEditSurvey_cleanupHint(t *testing.T) { var editorInitialText string editor := &testEditor{ From c08425aef1df7788188c6f92b3c3b31eea53665d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:06:27 -0600 Subject: [PATCH 2/4] fix(projects): use iostreams progress indicator --- pkg/cmd/project/shared/queries/queries.go | 56 ++++++++++------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 3e63465dd..70da05ab3 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -7,9 +7,7 @@ import ( "net/url" "regexp" "strings" - "time" - "github.com/briandowns/spinner" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" @@ -24,7 +22,7 @@ func NewClient(httpClient *http.Client, hostname string, ios *iostreams.IOStream } return &Client{ apiClient: apiClient, - spinner: ios.IsStdoutTTY() && ios.IsStderrTTY(), + io: ios, prompter: prompter.New("", ios.In, ios.Out, ios.ErrOut), } } @@ -44,9 +42,10 @@ func NewTestClient(opts ...TestClientOpt) *Client { hostname: "github.com", Client: api.NewClientFromHTTP(http.DefaultClient), } + io, _, _, _ := iostreams.Test() c := &Client{ apiClient: apiClient, - spinner: false, + io: io, prompter: nil, } @@ -80,7 +79,7 @@ type graphqlClient interface { type Client struct { apiClient graphqlClient - spinner bool + io *iostreams.IOStreams prompter iprompter } @@ -89,19 +88,12 @@ const ( LimitMax = 100 // https://docs.github.com/en/graphql/overview/resource-limitations#node-limit ) -// doQuery wraps API calls with a visual spinner -func (c *Client) doQuery(name string, query interface{}, variables map[string]interface{}) error { - var sp *spinner.Spinner - if c.spinner { - // https://github.com/briandowns/spinner#available-character-sets - dotStyle := spinner.CharSets[11] - sp = spinner.New(dotStyle, 120*time.Millisecond, spinner.WithColor("fgCyan")) - sp.Start() - } +// doQueryWithProgressIndicator wraps API calls with a progress indicator. +// The query name is used in the progress indicator label. +func (c *Client) doQueryWithProgressIndicator(name string, query interface{}, variables map[string]interface{}) error { + c.io.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching %s", name)) + defer c.io.StopProgressIndicator() err := c.apiClient.Query(name, query, variables) - if sp != nil { - sp.Stop() - } return handleError(err) } @@ -552,7 +544,7 @@ func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, erro query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries queryName = "ViewerProjectWithItems" } - err := c.doQuery(queryName, query, variables) + err := c.doQueryWithProgressIndicator(queryName, query, variables) if err != nil { return project, err } @@ -706,7 +698,7 @@ func paginateAttributes[N projectAttribute](c *Client, p pager[N], variables map // set the cursor to the end of the last page variables[afterKey] = (*githubv4.String)(&cursor) - err := c.doQuery(queryName, p, variables) + err := c.doQueryWithProgressIndicator(queryName, p, variables) if err != nil { return nodes, err } @@ -863,7 +855,7 @@ func (c *Client) ProjectFields(o *Owner, number int32, limit int) (*Project, err query = &viewerOwnerWithFields{} // must be a pointer to work with graphql queries queryName = "ViewerProjectWithFields" } - err := c.doQuery(queryName, query, variables) + err := c.doQueryWithProgressIndicator(queryName, query, variables) if err != nil { return project, err } @@ -977,7 +969,7 @@ const ViewerOwner OwnerType = "VIEWER" // ViewerLoginName returns the login name of the viewer. func (c *Client) ViewerLoginName() (string, error) { var query viewerLogin - err := c.doQuery("Viewer", &query, map[string]interface{}{}) + err := c.doQueryWithProgressIndicator("Viewer", &query, map[string]interface{}{}) if err != nil { return "", err } @@ -988,7 +980,7 @@ func (c *Client) ViewerLoginName() (string, error) { func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) { if login == "@me" || login == "" { var query viewerLogin - err := c.doQuery("ViewerOwner", &query, nil) + err := c.doQueryWithProgressIndicator("ViewerOwner", &query, nil) if err != nil { return "", "", err } @@ -1009,7 +1001,7 @@ func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) { } `graphql:"organization(login: $login)"` } - err := c.doQuery("UserOrgOwner", &query, variables) + err := c.doQueryWithProgressIndicator("UserOrgOwner", &query, variables) if err != nil { // Due to the way the queries are structured, we don't know if a login belongs to a user // or to an org, even though they are unique. To deal with this, we try both - if neither @@ -1052,7 +1044,7 @@ func (c *Client) IssueOrPullRequestID(rawURL string) (string, error) { "url": githubv4.URI{URL: uri}, } var query issueOrPullRequest - err = c.doQuery("GetIssueOrPullRequest", &query, variables) + err = c.doQueryWithProgressIndicator("GetIssueOrPullRequest", &query, variables) if err != nil { return "", err } @@ -1114,7 +1106,7 @@ func (c *Client) userOrgLogins() ([]loginTypes, error) { "after": (*githubv4.String)(nil), } - err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables) if err != nil { return l, err } @@ -1152,7 +1144,7 @@ func (c *Client) paginateOrgLogins(l []loginTypes, cursor string) ([]loginTypes, "after": githubv4.String(cursor), } - err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables) if err != nil { return l, err } @@ -1247,16 +1239,16 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool) if o.Type == UserOwner { var query userOwner variables["login"] = githubv4.String(o.Login) - err := c.doQuery("UserProject", &query, variables) + err := c.doQueryWithProgressIndicator("UserProject", &query, variables) return &query.Owner.Project, err } else if o.Type == OrgOwner { variables["login"] = githubv4.String(o.Login) var query orgOwner - err := c.doQuery("OrgProject", &query, variables) + err := c.doQueryWithProgressIndicator("OrgProject", &query, variables) return &query.Owner.Project, err } else if o.Type == ViewerOwner { var query viewerOwner - err := c.doQuery("ViewerProject", &query, variables) + err := c.doQueryWithProgressIndicator("ViewerProject", &query, variables) return &query.Owner.Project, err } return nil, errors.New("unknown owner type") @@ -1331,7 +1323,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr // the cost. if t == UserOwner { var query userProjects - if err := c.doQuery("UserProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("UserProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) @@ -1340,7 +1332,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr projects.TotalCount = query.Owner.Projects.TotalCount } else if t == OrgOwner { var query orgProjects - if err := c.doQuery("OrgProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("OrgProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) @@ -1349,7 +1341,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr projects.TotalCount = query.Owner.Projects.TotalCount } else if t == ViewerOwner { var query viewerProjects - if err := c.doQuery("ViewerProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("ViewerProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) From c208b1f07549914256b10e59c81f4f4f6f003ca4 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 20:49:37 +0200 Subject: [PATCH 3/4] Feature detect v1 projects on web mode issue create --- api/queries_repo.go | 94 +++++++-------- api/queries_repo_test.go | 171 ++++++++++++++++++++-------- pkg/cmd/issue/create/create.go | 22 ++-- pkg/cmd/issue/create/create_test.go | 68 +++++++++++ pkg/cmd/pr/create/create.go | 25 ++-- pkg/cmd/pr/shared/params.go | 10 +- pkg/cmd/pr/shared/params_test.go | 146 +++++++++++++++++++++++- pkg/cmd/pr/shared/state.go | 14 +-- pkg/cmd/pr/shared/survey.go | 4 +- pkg/cmd/pr/shared/survey_test.go | 4 +- 10 files changed, 420 insertions(+), 138 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 7c33825b0..27e21eb32 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" @@ -782,35 +783,54 @@ func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bo return "", false } -func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []string) ([]string, error) { - var paths []string - for _, projectName := range names { - found := false - for _, p := range projects { - if strings.EqualFold(projectName, p.Name) { - // format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER - // required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER - var path string - pathParts := strings.Split(p.ResourcePath, "/") - if pathParts[1] == "orgs" || pathParts[1] == "users" { - path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) - } else { - path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) +func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { + paths := make([]string, 0, len(projectNames)) + matchedPaths := map[string]struct{}{} + + // TODO: ProjectsV1Cleanup + // At this point, we only know the names that the user has provided, so we can't push this conditional up the stack. + // First we'll try to match against v1 projects, if supported + if projectsV1Support == gh.ProjectsV1Supported { + v1Projects, err := v1Projects(client, repo) + if err != nil { + return nil, err + } + + for _, projectName := range projectNames { + for _, p := range v1Projects { + if strings.EqualFold(projectName, p.Name) { + pathParts := strings.Split(p.ResourcePath, "/") + var path string + if pathParts[1] == "orgs" || pathParts[1] == "users" { + path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) + } else { + path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) + } + paths = append(paths, path) + matchedPaths[projectName] = struct{}{} + break } - paths = append(paths, path) - found = true - break } } - if found { + } + + // Then we'll try to match against v2 projects + v2Projects, err := v2Projects(client, repo) + if err != nil { + return nil, err + } + + for _, projectName := range projectNames { + // If we already found a v1 project with this name, skip it + if _, ok := matchedPaths[projectName]; ok { continue } - for _, p := range projectsV2 { + + found := false + for _, p := range v2Projects { if strings.EqualFold(projectName, p.Title) { - // format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER - // required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER - var path string pathParts := strings.Split(p.ResourcePath, "/") + var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) } else { @@ -821,10 +841,12 @@ func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []str break } } + if !found { return nil, fmt.Errorf("'%s' not found", projectName) } } + return paths, nil } @@ -1253,14 +1275,6 @@ 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) - if err != nil { - return nil, err - } - return ProjectsToPaths(projects, projectsV2, projectNames) -} - // v1Projects retrieves set of RepoProjects relevant to given repository: // - Projects for repository // - Projects for repository organization, if it belongs to one @@ -1366,26 +1380,6 @@ func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { return projectsV2, nil } -// relevantProjects retrieves set of Project or ProjectV2 relevant to given repository: -// - Projects for repository -// - Projects for repository organization, if it belongs to one -// - 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) { - v1Projects, err := v1Projects(client, repo) - if err != nil { - return nil, nil, err - } - - v2Projects, err := v2Projects(client, repo) - if err != nil { - return nil, nil, err - } - - return v1Projects, v2Projects, nil -} - func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { var responsev3 repositoryV3 err := apiClient.REST(hostname, method, path, body, &responsev3) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 5f5f55b67..72ed35776 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" @@ -212,37 +213,16 @@ func Test_RepoMetadata(t *testing.T) { } } -func Test_ProjectsToPaths(t *testing.T) { - expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER", "OWNER/REPO/PROJECT_NUMBER_2"} - projects := []RepoProject{ - {ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"}, - {ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"}, - {ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"}, - } - projectsV2 := []ProjectV2{ - {ID: "id4", Title: "My Project V2", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER_2"}, - {ID: "id5", Title: "Org Project V2", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_3"}, - } - projectNames := []string{"My Project", "Org Project", "My Project V2"} - - projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames) - if err != nil { - t.Errorf("error resolving projects: %v", err) - } - if !sliceEqual(projectPaths, expectedProjectPaths) { - t.Errorf("expected projects %v, got %v", expectedProjectPaths, projectPaths) - } -} - func Test_ProjectNamesToPaths(t *testing.T) { - http := &httpmock.Registry{} - client := newTestClient(http) + t.Run("when projectsV1 is supported, requests them", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) - repo, _ := ghrepo.FromFullName("OWNER/REPO") + repo, _ := ghrepo.FromFullName("OWNER/REPO") - http.Register( - httpmock.GraphQL(`query RepositoryProjectList\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` { "data": { "repository": { "projects": { "nodes": [ { "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }, @@ -251,9 +231,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectList\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` { "data": { "organization": { "projects": { "nodes": [ { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } @@ -261,9 +241,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query RepositoryProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` { "data": { "repository": { "projectsV2": { "nodes": [ { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" }, @@ -272,9 +252,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` { "data": { "organization": { "projectsV2": { "nodes": [ { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } @@ -282,9 +262,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query UserProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` { "data": { "viewer": { "projectsV2": { "nodes": [ { "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" } @@ -293,15 +273,110 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"} - if !sliceEqual(projectPaths, expectedProjectPaths) { - t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) - } + expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"} + if !sliceEqual(projectPaths, expectedProjectPaths) { + t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) + } + }) + + t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + http.Exclude( + t, + httpmock.GraphQL(`query RepositoryProjectList\b`), + ) + http.Exclude( + t, + httpmock.GraphQL(`query OrganizationProjectList\b`), + ) + + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [ + { "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedProjectPaths := []string{"ORG/2", "OWNER/REPO/4", "MONALISA/5"} + if !sliceEqual(projectPaths, expectedProjectPaths) { + t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) + } + }) + + t.Run("when a project is not found, returns an error", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + // No projects found + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + _, err := ProjectNamesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported) + require.Equal(t, err, fmt.Errorf("'TriageV2' not found")) + }) } func Test_RepoResolveMetadataIDs(t *testing.T) { diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 0976fd94f..2978a21fc 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -173,13 +173,13 @@ func createRun(opts *CreateOptions) (err error) { } tb := prShared.IssueMetadataState{ - Type: prShared.IssueMetadata, - Assignees: assignees, - Labels: opts.Labels, - Projects: opts.Projects, - Milestones: milestones, - Title: opts.Title, - Body: opts.Body, + Type: prShared.IssueMetadata, + Assignees: assignees, + Labels: opts.Labels, + ProjectTitles: opts.Projects, + Milestones: milestones, + Title: opts.Title, + Body: opts.Body, } if opts.RecoverFile != "" { @@ -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) + 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) + openURL, err = generatePreviewURL(apiClient, baseRepo, 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) (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) + return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index d8c1c5d92..1211c0c1d 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -474,6 +474,7 @@ func Test_createRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.Detector = &fd.EnabledDetectorMock{} browser := &browser.Stub{} opts.Browser = browser @@ -1103,4 +1104,71 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) }) + + t.Run("web mode", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.EnabledDetectorMock{}, + WebMode: true, + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.DisabledDetectorMock{}, + WebMode: true, + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 27c929dc4..7f960bce4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -625,13 +625,13 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata } state := &shared.IssueMetadataState{ - Type: shared.PRMetadata, - Reviewers: opts.Reviewers, - Assignees: assignees, - Labels: opts.Labels, - Projects: opts.Projects, - Milestones: milestoneTitles, - Draft: opts.IsDraft, + Type: shared.PRMetadata, + Reviewers: opts.Reviewers, + Assignees: assignees, + Labels: opts.Labels, + ProjectTitles: opts.Projects, + Milestones: milestoneTitles, + Draft: opts.IsDraft, } if opts.FillVerbose || opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided { @@ -1032,8 +1032,8 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s if len(state.Milestones) != 0 { fmt.Fprintf(w, "milestones:\t%v\n", strings.Join(state.Milestones, ", ")) } - if len(state.Projects) != 0 { - fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.Projects, ", ")) + if len(state.ProjectTitles) != 0 { + fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.ProjectTitles, ", ")) } fmt.Fprintf(w, "maintainerCanModify:\t%t\n", params["maintainerCanModify"]) fmt.Fprint(w, "body:\n") @@ -1064,8 +1064,8 @@ func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{} if len(state.Milestones) != 0 { fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", ")) } - if len(state.Projects) != 0 { - fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.Projects, ", ")) + if len(state.ProjectTitles) != 0 { + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.ProjectTitles, ", ")) } fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"]) @@ -1221,7 +1221,8 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state) + // TODO wm: revisit project support + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, gh.ProjectsV1Supported) if err != nil { return "", err } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 9688a2aca..4f36a80aa 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -12,7 +12,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, projectsV1Support gh.ProjectsV1Support) (string, error) { u, err := url.Parse(baseURL) if err != nil { return "", err @@ -35,8 +35,8 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba if len(state.Labels) > 0 { q.Set("labels", strings.Join(state.Labels, ",")) } - if len(state.Projects) > 0 { - projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects) + if len(state.ProjectTitles) > 0 { + projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support) if err != nil { return "", fmt.Errorf("could not add to project: %w", err) } @@ -72,7 +72,7 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada resolveInput.Labels = tb.Labels } - if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { + if len(tb.ProjectTitles) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { if projectV1Support == gh.ProjectsV1Supported { resolveInput.ProjectsV1 = true } @@ -119,7 +119,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } params["labelIds"] = labelIDs - projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) + projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.ProjectTitles) if err != nil { return fmt.Errorf("could not add to project: %w", err) } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 5f5e674cc..15f00ca4f 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -2,13 +2,16 @@ package shared import ( "net/http" + "net/url" "reflect" "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" + "github.com/stretchr/testify/require" ) func Test_listURLWithQuery(t *testing.T) { @@ -265,7 +268,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, gh.ProjectsV1Supported) if (err != nil) != tt.wantErr { t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr) return @@ -276,3 +279,144 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) { }) } } + +// TODO projectsV1Deprecation +// Remove this test. +func TestWithPrAndIssueQueryParamsProjectsV1Deprecation(t *testing.T) { + t.Run("when projectsV1 is supported, requests them", func(t *testing.T) { + reg := &httpmock.Registry{} + client := api.NewClientFromHTTP(&http.Client{ + Transport: reg, + }) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + reg.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [ + { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + u, err := WithPrAndIssueQueryParams( + client, + repo, + "http://example.com/hey", + IssueMetadataState{ + ProjectTitles: []string{"Triage"}, + }, + gh.ProjectsV1Supported, + ) + require.NoError(t, err) + + url, err := url.Parse(u) + require.NoError(t, err) + + require.Equal( + t, + url.Query().Get("projects"), + "ORG/1", + ) + }) + + t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) { + reg := &httpmock.Registry{} + client := api.NewClientFromHTTP(&http.Client{ + Transport: reg, + }) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + reg.Exclude( + t, + httpmock.GraphQL(`query RepositoryProjectList\b`), + ) + reg.Exclude( + t, + httpmock.GraphQL(`query OrganizationProjectList\b`), + ) + + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + u, err := WithPrAndIssueQueryParams( + client, + repo, + "http://example.com/hey", + IssueMetadataState{ + ProjectTitles: []string{"TriageV2"}, + }, + gh.ProjectsV1Unsupported, + ) + require.NoError(t, err) + + url, err := url.Parse(u) + require.NoError(t, err) + + require.Equal( + t, + url.Query().Get("projects"), + "ORG/2", + ) + }) +} diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go index 143021cb6..7e7da436d 100644 --- a/pkg/cmd/pr/shared/state.go +++ b/pkg/cmd/pr/shared/state.go @@ -25,12 +25,12 @@ type IssueMetadataState struct { Template string - Metadata []string - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestones []string + Metadata []string + Reviewers []string + Assignees []string + Labels []string + ProjectTitles []string + Milestones []string MetadataResult *api.RepoMetadataResult @@ -49,7 +49,7 @@ func (tb *IssueMetadataState) HasMetadata() bool { return len(tb.Reviewers) > 0 || len(tb.Assignees) > 0 || len(tb.Labels) > 0 || - len(tb.Projects) > 0 || + len(tb.ProjectTitles) > 0 || len(tb.Milestones) > 0 } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 23079a391..bf4476ca1 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -268,7 +268,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } if isChosen("Projects") { if len(projects) > 0 { - selected, err := p.MultiSelect("Projects", state.Projects, projects) + selected, err := p.MultiSelect("Projects", state.ProjectTitles, projects) if err != nil { return err } @@ -317,7 +317,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface state.Labels = values.Labels } if isChosen("Projects") { - state.Projects = values.Projects + state.ProjectTitles = values.Projects } if isChosen("Milestone") { if values.Milestone != "" && values.Milestone != noMilestone { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index c094b7556..6895b52ac 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -80,7 +80,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { assert.Equal(t, []string{"hubot"}, state.Assignees) assert.Equal(t, []string{"monalisa"}, state.Reviewers) assert.Equal(t, []string{"good first issue"}, state.Labels) - assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles) assert.Equal(t, []string{}, state.Milestones) } @@ -125,7 +125,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { assert.Equal(t, []string{"hubot"}, state.Assignees) assert.Equal(t, []string{"good first issue"}, state.Labels) - assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles) } // TODO projectsV1Deprecation From 0aa49b774150628a47b54f2b6a5cf02b08ce1c87 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 21:19:41 +0200 Subject: [PATCH 4/4] Feature detect v1 projects on issue edit --- pkg/cmd/issue/edit/edit.go | 17 ++++++- pkg/cmd/issue/edit/edit_test.go | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index accea8add..8386cbcfa 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -5,9 +5,12 @@ 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/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" @@ -22,6 +25,7 @@ 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 @@ -201,7 +205,18 @@ func editRun(opts *EditOptions) error { lookupFields = append(lookupFields, "labels") } if editable.Projects.Edited { - lookupFields = append(lookupFields, "projectCards") + // TODO projectsV1Deprecation + // Remove this section as we should no longer add projectCards + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + if projectsV1Support == gh.ProjectsV1Supported { + lookupFields = append(lookupFields, "projectCards") + } + lookupFields = append(lookupFields, "projectItems") } if editable.Milestone.Edited { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index a9d43c3ec..c9aa4c409 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -10,7 +10,9 @@ 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/ghrepo" + "github.com/cli/cli/v2/internal/run" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -788,3 +790,88 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { func(inputs map[string]interface{}) {}), ) } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.EnabledDetectorMock{}, + + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Test Project"}, + Edited: true, + }, + }, + }, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude(t, httpmock.GraphQL(`projectCards`)) + + reg.Register( + httpmock.GraphQL(`.*`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.DisabledDetectorMock{}, + + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Test Project"}, + Edited: true, + }, + }, + }, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) +}