diff --git a/api/queries_repo.go b/api/queries_repo.go index 7c33825b004..27e21eb32ac 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 5f5f55b67b7..72ed357760b 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 0976fd94f15..2978a21fc7b 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 d8c1c5d92e1..1211c0c1d93 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 27c929dc41e..7f960bce446 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 9688a2aca69..4f36a80aaa5 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 5f5e674cc0f..15f00ca4f22 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 143021cb60b..7e7da436d03 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 23079a39179..bf4476ca1ed 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 c094b7556f6..6895b52ac99 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