From 179e9c256df518cde305a688ef5082903ed35fcd Mon Sep 17 00:00:00 2001 From: Ilya Yatsishin <2159081+qoega@users.noreply.github.com> Date: Thu, 19 Jan 2023 23:13:09 +0100 Subject: [PATCH] Add projectsV2 support to issue create, issue edit, pr create, and pr edit (#6735) Co-authored-by: pshevche Co-authored-by: Sam Coe --- api/queries_issue.go | 47 +++++- api/queries_org.go | 41 ++++- api/queries_pr.go | 14 ++ api/queries_projects_v2.go | 144 ++++++++++++++++ api/queries_projects_v2_test.go | 252 ++++++++++++++++++++++++++++ api/queries_repo.go | 192 ++++++++++++++++++--- api/queries_repo_test.go | 76 +++++++-- api/query_builder.go | 2 + pkg/cmd/issue/create/create_test.go | 121 ++++++++++++- pkg/cmd/issue/edit/edit.go | 8 +- pkg/cmd/issue/edit/edit_test.go | 91 ++++++++-- pkg/cmd/issue/shared/lookup.go | 15 ++ pkg/cmd/pr/create/create_test.go | 127 ++++++++++++++ pkg/cmd/pr/edit/edit.go | 9 +- pkg/cmd/pr/edit/edit_test.go | 79 +++++++-- pkg/cmd/pr/shared/editable.go | 62 ++++++- pkg/cmd/pr/shared/editable_http.go | 23 +++ pkg/cmd/pr/shared/finder.go | 18 +- pkg/cmd/pr/shared/params.go | 3 +- pkg/cmd/pr/shared/survey.go | 7 +- 20 files changed, 1249 insertions(+), 82 deletions(-) create mode 100644 api/queries_projects_v2.go create mode 100644 api/queries_projects_v2_test.go diff --git a/api/queries_issue.go b/api/queries_issue.go index 1fceac39e..58053f8df 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -40,6 +40,7 @@ type Issue struct { Assignees Assignees Labels Labels ProjectCards ProjectCards + ProjectItems ProjectItems Milestone *Milestone ReactionGroups ReactionGroups IsPinned bool @@ -86,6 +87,10 @@ type ProjectCards struct { TotalCount int } +type ProjectItems struct { + Nodes []*ProjectV2Item +} + type ProjectInfo struct { Project struct { Name string `json:"name"` @@ -95,6 +100,14 @@ type ProjectInfo struct { } `json:"column"` } +type ProjectV2Item struct { + ID string `json:"id"` + Project struct { + ID string `json:"id"` + Title string `json:"title"` + } +} + func (p ProjectCards) ProjectNames() []string { names := make([]string, len(p.Nodes)) for i, c := range p.Nodes { @@ -103,6 +116,14 @@ func (p ProjectCards) ProjectNames() []string { return names } +func (p ProjectItems) ProjectTitles() []string { + titles := make([]string, len(p.Nodes)) + for i, c := range p.Nodes { + titles[i] = c.Project.Title + } + return titles +} + type Milestone struct { Number int `json:"number"` Title string `json:"title"` @@ -158,6 +179,7 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} mutation IssueCreate($input: CreateIssueInput!) { createIssue(input: $input) { issue { + id url } } @@ -167,7 +189,13 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} "repositoryId": repo.ID, } for key, val := range params { - inputParams[key] = val + switch key { + case "assigneeIds", "body", "issueTemplate", "labelIds", "milestoneId", "projectIds", "repositoryId", "title": + inputParams[key] = val + case "projectV2Ids": + default: + return nil, fmt.Errorf("invalid IssueCreate mutation parameter %s", key) + } } variables := map[string]interface{}{ "input": inputParams, @@ -183,8 +211,23 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} if err != nil { return nil, err } + issue := &result.CreateIssue.Issue - return &result.CreateIssue.Issue, nil + // projectV2 parameters aren't supported in the `createIssue` mutation, + // so add them after the issue has been created. + projectV2Ids, ok := params["projectV2Ids"].([]string) + if ok { + projectItems := make(map[string]string, len(projectV2Ids)) + for _, p := range projectV2Ids { + projectItems[p] = issue.ID + } + err = UpdateProjectV2Items(client, repo, projectItems, nil) + if err != nil { + return issue, err + } + } + + return issue, nil } type IssueStatusOptions struct { diff --git a/api/queries_org.go b/api/queries_org.go index 502b6f39e..e57d7f08b 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -5,7 +5,7 @@ import ( "github.com/shurcooL/githubv4" ) -// OrganizationProjects fetches all open projects for an organization +// OrganizationProjects fetches all open projects for an organization. func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { type responseData struct { Organization struct { @@ -42,6 +42,45 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, return projects, nil } +// OrganizationProjectsV2 fetches all open projectsV2 for an organization. +func OrganizationProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) { + type responseData struct { + Organization struct { + ProjectsV2 struct { + Nodes []RepoProjectV2 + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projectsV2(first: 100, orderBy: {field: TITLE, direction: ASC}, after: $endCursor, query: $query)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "endCursor": (*githubv4.String)(nil), + "query": githubv4.String("is:open"), + } + + var projectsV2 []RepoProjectV2 + for { + var query responseData + err := client.Query(repo.RepoHost(), "OrganizationProjectV2List", &query, variables) + if err != nil { + return nil, err + } + + projectsV2 = append(projectsV2, query.Organization.ProjectsV2.Nodes...) + + if !query.Organization.ProjectsV2.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Organization.ProjectsV2.PageInfo.EndCursor) + } + + return projectsV2, nil +} + type OrgTeam struct { ID string Slug string diff --git a/api/queries_pr.go b/api/queries_pr.go index af4e6bb0f..87ffd658d 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -74,6 +74,7 @@ type PullRequest struct { Assignees Assignees Labels Labels ProjectCards ProjectCards + ProjectItems ProjectItems Milestone *Milestone Comments Comments ReactionGroups ReactionGroups @@ -378,6 +379,19 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } } + // projectsV2 are added in yet another mutation + projectV2Ids, ok := params["projectV2Ids"].([]string) + if ok { + projectItems := make(map[string]string, len(projectV2Ids)) + for _, p := range projectV2Ids { + projectItems[p] = pr.ID + } + err = UpdateProjectV2Items(client, repo, projectItems, nil) + if err != nil { + return pr, err + } + } + return pr, nil } diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go new file mode 100644 index 000000000..9609524f1 --- /dev/null +++ b/api/queries_projects_v2.go @@ -0,0 +1,144 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" +) + +const ( + errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']" + errorProjectsV2RepositoryField = "Field 'ProjectsV2' doesn't exist on type 'Repository'" + errorProjectsV2OrganizationField = "Field 'ProjectsV2' doesn't exist on type 'Organization'" + errorProjectsV2IssueField = "Field 'ProjectItems' doesn't exist on type 'Issue'" + errorProjectsV2PullRequestField = "Field 'ProjectItems' doesn't exist on type 'PullRequest'" +) + +// UpdateProjectV2Items uses the addProjectV2ItemById and the deleteProjectV2Item mutations +// to add and delete items from projects. The addProjectItems and deleteProjectItems arguments are +// mappings between a project and an item. This function can be used across multiple projects +// and items. Note that the deleteProjectV2Item mutation requires the item id from the project not +// the global id. +func UpdateProjectV2Items(client *Client, repo ghrepo.Interface, addProjectItems, deleteProjectItems map[string]string) error { + l := len(addProjectItems) + len(deleteProjectItems) + if l == 0 { + return nil + } + inputs := make([]string, 0, l) + mutations := make([]string, 0, l) + variables := make(map[string]interface{}, l) + var i int + + for project, item := range addProjectItems { + inputs = append(inputs, fmt.Sprintf("$input_%03d: AddProjectV2ItemByIdInput!", i)) + mutations = append(mutations, fmt.Sprintf("add_%03d: addProjectV2ItemById(input: $input_%03d) { item { id } }", i, i)) + variables[fmt.Sprintf("input_%03d", i)] = map[string]interface{}{"contentId": item, "projectId": project} + i++ + } + + for project, item := range deleteProjectItems { + inputs = append(inputs, fmt.Sprintf("$input_%03d: DeleteProjectV2ItemInput!", i)) + mutations = append(mutations, fmt.Sprintf("delete_%03d: deleteProjectV2Item(input: $input_%03d) { deletedItemId }", i, i)) + variables[fmt.Sprintf("input_%03d", i)] = map[string]interface{}{"itemId": item, "projectId": project} + i++ + } + + query := fmt.Sprintf(`mutation UpdateProjectV2Items(%s) {%s}`, strings.Join(inputs, " "), strings.Join(mutations, " ")) + + return client.GraphQL(repo.RepoHost(), query, variables, nil) +} + +// ProjectsV2ItemsForIssue fetches all ProjectItems for an issue. +func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) error { + type response struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []*ProjectV2Item + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projectItems(first: 100, after: $endCursor)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(issue.Number), + "endCursor": (*githubv4.String)(nil), + } + var items ProjectItems + for { + var query response + err := client.Query(repo.RepoHost(), "IssueProjectItems", &query, variables) + if err != nil { + return err + } + items.Nodes = append(items.Nodes, query.Repository.Issue.ProjectItems.Nodes...) + if !query.Repository.Issue.ProjectItems.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.Issue.ProjectItems.PageInfo.EndCursor) + } + issue.ProjectItems = items + return nil +} + +// ProjectsV2ItemsForPullRequest fetches all ProjectItems for a pull request. +func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) error { + type response struct { + Repository struct { + PullRequest struct { + ProjectItems struct { + Nodes []*ProjectV2Item + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projectItems(first: 100, after: $endCursor)"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(pr.Number), + "endCursor": (*githubv4.String)(nil), + } + var items ProjectItems + for { + var query response + err := client.Query(repo.RepoHost(), "PullRequestProjectItems", &query, variables) + if err != nil { + return err + } + items.Nodes = append(items.Nodes, query.Repository.PullRequest.ProjectItems.Nodes...) + if !query.Repository.PullRequest.ProjectItems.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.PullRequest.ProjectItems.PageInfo.EndCursor) + } + pr.ProjectItems = items + return nil +} + +// When querying ProjectsV2 fields we generally dont want to show the user +// scope errors and field does not exist errors. ProjectsV2IgnorableError +// checks against known error strings to see if an error can be safely ignored. +// Due to the fact that the GQLClient can return multiple types of errors +// this uses brittle string comparison to check against the known error strings. +func ProjectsV2IgnorableError(err error) bool { + msg := err.Error() + if strings.Contains(msg, errorProjectsV2ReadScope) || + strings.Contains(msg, errorProjectsV2RepositoryField) || + strings.Contains(msg, errorProjectsV2OrganizationField) || + strings.Contains(msg, errorProjectsV2IssueField) || + strings.Contains(msg, errorProjectsV2PullRequestField) { + return true + } + return false +} diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go new file mode 100644 index 000000000..252a7e110 --- /dev/null +++ b/api/queries_projects_v2_test.go @@ -0,0 +1,252 @@ +package api + +import ( + "errors" + "strings" + "testing" + "unicode" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestUpdateProjectV2Items(t *testing.T) { + var tests = []struct { + name string + httpStubs func(*httpmock.Registry) + expectError bool + }{ + { + name: "updates project items", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLQuery(`{"data":{"add_000":{"item":{"id":"1"}},"delete_001":{"item":{"id":"2"}}}}`, + func(mutations string, inputs map[string]interface{}) { + expectedMutations := ` + mutation UpdateProjectV2Items( + $input_000: AddProjectV2ItemByIdInput! + $input_001: AddProjectV2ItemByIdInput! + $input_002: DeleteProjectV2ItemInput! + $input_003: DeleteProjectV2ItemInput! + ) { + add_000: addProjectV2ItemById(input: $input_000) { item { id } } + add_001: addProjectV2ItemById(input: $input_001) { item { id } } + delete_002: deleteProjectV2Item(input: $input_002) { deletedItemId } + delete_003: deleteProjectV2Item(input: $input_003) { deletedItemId } + }` + expectedVariables := map[string]interface{}{ + "input_000": map[string]interface{}{"contentId": "item1", "projectId": "project1"}, + "input_001": map[string]interface{}{"contentId": "item2", "projectId": "project2"}, + "input_002": map[string]interface{}{"itemId": "item3", "projectId": "project3"}, + "input_003": map[string]interface{}{"itemId": "item4", "projectId": "project4"}, + } + assert.Equal(t, stripSpace(expectedMutations), stripSpace(mutations)) + assert.Equal(t, expectedVariables, inputs) + })) + }, + }, + { + name: "fails to update project items", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLMutation(`{"data":{}, "errors": [{"message": "some gql error"}]}`, func(inputs map[string]interface{}) {}), + ) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + client := newTestClient(reg) + repo, _ := ghrepo.FromFullName("OWNER/REPO") + addProjectItems := map[string]string{"project1": "item1", "project2": "item2"} + deleteProjectItems := map[string]string{"project3": "item3", "project4": "item4"} + err := UpdateProjectV2Items(client, repo, addProjectItems, deleteProjectItems) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestProjectsV2ItemsForIssue(t *testing.T) { + var tests = []struct { + name string + httpStubs func(*httpmock.Registry) + expectItems ProjectItems + expectError bool + }{ + { + name: "retrieves project items for issue", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueProjectItems\b`), + httpmock.GraphQLQuery(`{"data":{"repository":{"issue":{"projectItems":{"nodes": [{"id":"projectItem1"},{"id":"projectItem2"}]}}}}}`, + func(query string, inputs map[string]interface{}) {}), + ) + }, + expectItems: ProjectItems{ + Nodes: []*ProjectV2Item{ + {ID: "projectItem1"}, + {ID: "projectItem2"}, + }, + }, + }, + { + name: "fails to retrieve project items for issue", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueProjectItems\b`), + httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`, + func(query string, inputs map[string]interface{}) {}), + ) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + client := newTestClient(reg) + repo, _ := ghrepo.FromFullName("OWNER/REPO") + issue := &Issue{Number: 1} + err := ProjectsV2ItemsForIssue(client, repo, issue) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expectItems, issue.ProjectItems) + }) + } +} + +func TestProjectsV2ItemsForPullRequest(t *testing.T) { + var tests = []struct { + name string + httpStubs func(*httpmock.Registry) + expectItems ProjectItems + expectError bool + }{ + { + name: "retrieves project items for pull request", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestProjectItems\b`), + httpmock.GraphQLQuery(`{"data":{"repository":{"pullRequest":{"projectItems":{"nodes": [{"id":"projectItem3"},{"id":"projectItem4"}]}}}}}`, + func(query string, inputs map[string]interface{}) {}), + ) + }, + expectItems: ProjectItems{ + Nodes: []*ProjectV2Item{ + {ID: "projectItem3"}, + {ID: "projectItem4"}, + }, + }, + }, + { + name: "fails to retrieve project items for pull request", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestProjectItems\b`), + httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`, + func(query string, inputs map[string]interface{}) {}), + ) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + client := newTestClient(reg) + repo, _ := ghrepo.FromFullName("OWNER/REPO") + pr := &PullRequest{Number: 1} + err := ProjectsV2ItemsForPullRequest(client, repo, pr) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expectItems, pr.ProjectItems) + }) + } +} + +func TestProjectsV2IgnorableError(t *testing.T) { + var tests = []struct { + name string + errMsg string + expectOut bool + }{ + { + name: "read scope error", + errMsg: "field requires one of the following scopes: ['read:project']", + expectOut: true, + }, + { + name: "repository projectsV2 field error", + errMsg: "Field 'ProjectsV2' doesn't exist on type 'Repository'", + expectOut: true, + }, + { + name: "organization projectsV2 field error", + errMsg: "Field 'ProjectsV2' doesn't exist on type 'Organization'", + expectOut: true, + }, + { + name: "issue projectItems field error", + errMsg: "Field 'ProjectItems' doesn't exist on type 'Issue'", + expectOut: true, + }, + { + name: "pullRequest projectItems field error", + errMsg: "Field 'ProjectItems' doesn't exist on type 'PullRequest'", + expectOut: true, + }, + { + name: "other error", + errMsg: "some other graphql error message", + expectOut: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := errors.New(tt.errMsg) + out := ProjectsV2IgnorableError(err) + assert.Equal(t, tt.expectOut, out) + }) + } +} + +func stripSpace(str string) string { + var b strings.Builder + b.Grow(len(str)) + for _, ch := range str { + if !unicode.IsSpace(ch) { + b.WriteRune(ch) + } + } + return b.String() +} diff --git a/api/queries_repo.go b/api/queries_repo.go index c5a0f5094..b7d257755 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "time" "github.com/cli/cli/v2/internal/ghinstance" + "golang.org/x/sync/errgroup" "github.com/cli/cli/v2/internal/ghrepo" ghAPI "github.com/cli/go-gh/pkg/api" @@ -647,6 +649,7 @@ type RepoMetadataResult struct { AssignableUsers []RepoAssignee Labels []RepoLabel Projects []RepoProject + ProjectsV2 []RepoProjectV2 Milestones []RepoMilestone Teams []OrgTeam } @@ -706,25 +709,52 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { return ids, nil } -func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) { +// ProjectsToIDs returns two arrays: +// - the first contains IDs of projects V1 +// - the second contains IDs of projects V2 +// - if neither project V1 or project V2 can be found with a given name, then an error is returned +func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, []string, error) { var ids []string + var idsV2 []string for _, projectName := range names { - found := false - for _, p := range m.Projects { - if strings.EqualFold(projectName, p.Name) { - ids = append(ids, p.ID) - found = true - break - } + id, found := m.projectNameToID(projectName) + if found { + ids = append(ids, id) + continue } - if !found { - return nil, fmt.Errorf("'%s' not found", projectName) + + idV2, found := m.projectV2TitleToID(projectName) + if found { + idsV2 = append(idsV2, idV2) + continue } + + return nil, nil, fmt.Errorf("'%s' not found", projectName) } - return ids, nil + return ids, idsV2, nil } -func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) { +func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool) { + for _, p := range m.Projects { + if strings.EqualFold(projectName, p.Name) { + return p.ID, true + } + } + + return "", false +} + +func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bool) { + for _, p := range m.ProjectsV2 { + if strings.EqualFold(projectTitle, p.Title) { + return p.ID, true + } + } + + return "", false +} + +func ProjectsToPaths(projects []RepoProject, projectsV2 []RepoProjectV2, names []string) ([]string, error) { var paths []string for _, projectName := range names { found := false @@ -744,6 +774,25 @@ func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) { break } } + if found { + continue + } + for _, p := range projectsV2 { + if strings.EqualFold(projectName, p.Title) { + // format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER + // required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER + var path string + pathParts := strings.Split(p.ResourcePath, "/") + if pathParts[1] == "orgs" { + 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) + found = true + break + } + } if !found { return nil, fmt.Errorf("'%s' not found", projectName) } @@ -854,6 +903,18 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput errc <- nil }() } + if input.Projects { + count++ + go func() { + projectsV2, err := RepoAndOrgProjectsV2(client, repo) + if err != nil { + errc <- err + return + } + result.ProjectsV2 = projectsV2 + errc <- nil + }() + } if input.Milestones { count++ go func() { @@ -985,7 +1046,15 @@ type RepoProject struct { ResourcePath string `json:"resourcePath"` } -// RepoProjects fetches all open projects for a repository +type RepoProjectV2 struct { + ID string `json:"id"` + Title string `json:"title"` + Number int `json:"number"` + ResourcePath string `json:"resourcePath"` + Closed bool `json:"closed"` +} + +// RepoProjects fetches all open projects for a repository. func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { type responseData struct { Repository struct { @@ -1023,23 +1092,87 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) return projects, nil } -// RepoAndOrgProjects fetches all open projects for a repository and its org +// RepoProjectsV2 fetches all open projectsV2 for a repository. +func RepoProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) { + type responseData struct { + Repository struct { + ProjectsV2 struct { + Nodes []RepoProjectV2 + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projectsV2(first: 100, orderBy: {field: TITLE, direction: ASC}, after: $endCursor, query: $query)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + "query": githubv4.String("is:open"), + } + + var projectsV2 []RepoProjectV2 + for { + var query responseData + err := client.Query(repo.RepoHost(), "RepositoryProjectV2List", &query, variables) + if err != nil { + return nil, err + } + + projectsV2 = append(projectsV2, query.Repository.ProjectsV2.Nodes...) + + if !query.Repository.ProjectsV2.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.ProjectsV2.PageInfo.EndCursor) + } + + return projectsV2, nil +} + +// RepoAndOrgProjects fetches all open projects for a repository and its organization. func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { projects, err := RepoProjects(client, repo) if err != nil { - return projects, fmt.Errorf("error fetching projects: %w", err) + return nil, fmt.Errorf("error fetching projects: %w", err) } orgProjects, err := OrganizationProjects(client, repo) - // TODO: better detection of non-org repos + // TODO: Better detection of non-org repos. if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") { - return projects, fmt.Errorf("error fetching organization projects: %w", err) + return nil, fmt.Errorf("error fetching organization projects: %w", err) } + projects = append(projects, orgProjects...) return projects, nil } +// RepoAndOrgProjectsV2 fetches all open projectsV2 for a repository and its organization. +// Note: If the auth token does not have sufficient scopes or projectsV2 is not supported +// on the host then those errors are swallowed and nil is returned. +func RepoAndOrgProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) { + projectsV2, err := RepoProjectsV2(client, repo) + if err != nil { + if ProjectsV2IgnorableError(err) { + return nil, nil + } + + return nil, fmt.Errorf("error fetching projectsV2: %w", err) + } + + orgProjectsV2, err := OrganizationProjectsV2(client, repo) + if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") { + return nil, fmt.Errorf("error fetching organization projectsV2: %w", err) + } + + projectsV2 = append(projectsV2, orgProjectsV2...) + + return projectsV2, nil +} + type RepoAssignee struct { ID string Login string @@ -1192,12 +1325,27 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo } func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) { - var paths []string - projects, err := RepoAndOrgProjects(client, repo) - if err != nil { - return paths, err + g, _ := errgroup.WithContext(context.Background()) + var projects []RepoProject + var projectsV2 []RepoProjectV2 + + g.Go(func() error { + var err error + projects, err = RepoAndOrgProjects(client, repo) + return err + }) + + g.Go(func() error { + var err error + projectsV2, err = RepoAndOrgProjectsV2(client, repo) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err } - return ProjectsToPaths(projects, projectNames) + + return ProjectsToPaths(projects, projectsV2, projectNames) } 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 da306de51..15276cc50 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -89,6 +89,17 @@ func Test_RepoMetadata(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) http.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), httpmock.StringResponse(` @@ -99,6 +110,16 @@ func Test_RepoMetadata(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) http.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), httpmock.StringResponse(` @@ -149,13 +170,17 @@ func Test_RepoMetadata(t *testing.T) { } expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"} - projectIDs, err := result.ProjectsToIDs([]string{"triage", "roadmap"}) + expectedProjectV2IDs := []string{"TRIAGEV2ID", "ROADMAPV2ID"} + projectIDs, projectV2IDs, err := result.ProjectsToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2"}) if err != nil { t.Errorf("error resolving projects: %v", err) } if !sliceEqual(projectIDs, expectedProjectIDs) { t.Errorf("expected projects %v, got %v", expectedProjectIDs, projectIDs) } + if !sliceEqual(projectV2IDs, expectedProjectV2IDs) { + t.Errorf("expected projectsV2 %v, got %v", expectedProjectV2IDs, projectV2IDs) + } expectedMilestoneID := "BIGONEID" milestoneID, err := result.MilestoneToID("big one.oh") @@ -173,15 +198,19 @@ func Test_RepoMetadata(t *testing.T) { } func Test_ProjectsToPaths(t *testing.T) { - expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"} + 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"}, } - projectNames := []string{"My Project", "Org Project"} + projectsV2 := []RepoProjectV2{ + {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, projectNames) + projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames) if err != nil { t.Errorf("error resolving projects: %v", err) } @@ -210,20 +239,41 @@ func Test_ProjectNamesToPaths(t *testing.T) { http.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), httpmock.StringResponse(` - { "data": { "organization": { "projects": { - "nodes": [ - { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) + { "data": { "organization": { "projects": { + "nodes": [ + { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + 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 } + } } } } + `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap"}) + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2"} + expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4"} if !sliceEqual(projectPaths, expectedProjectPaths) { t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) } diff --git a/api/query_builder.go b/api/query_builder.go index 7648148ff..297e997ef 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -274,6 +274,8 @@ func IssueGraphQL(fields []string) string { q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`) case "projectCards": q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`) + case "projectItems": + q = append(q, `projectItems(first:100){nodes{id, project{id,title}},totalCount}`) case "milestone": q = append(q, `milestone{number,title,description,dueOn}`) case "reactionGroups": diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 9bfaea8b5..de98d52b4 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -217,6 +217,24 @@ func Test_createRun(t *testing.T) { ], "pageInfo": { "hasNextPage": false } } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } }`)) + r.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } }`)) }, wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1", wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n", @@ -612,6 +630,22 @@ func TestIssueCreate_metadata(t *testing.T) { }] } `)) + 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(`mutation IssueCreate\b`), httpmock.GraphQLMutation(` @@ -625,12 +659,9 @@ func TestIssueCreate_metadata(t *testing.T) { assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) assert.Equal(t, "BIGONEID", inputs["milestoneId"]) - if v, ok := inputs["userIds"]; ok { - t.Errorf("did not expect userIds: %v", v) - } - if v, ok := inputs["teamIds"]; ok { - t.Errorf("did not expect teamIds: %v", v) - } + assert.NotContains(t, inputs, "userIds") + assert.NotContains(t, inputs, "teamIds") + assert.NotContains(t, inputs, "projectV2Ids") })) output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`, nil) @@ -712,3 +743,81 @@ func TestIssueCreate_AtMeAssignee(t *testing.T) { assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } + +func TestIssueCreate_projectsV2(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "main") + http.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TriageV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "id": "Issue#1", + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `, func(inputs map[string]interface{}) { + assert.Equal(t, "TITLE", inputs["title"]) + assert.Equal(t, "BODY", inputs["body"]) + assert.Nil(t, inputs["projectIds"]) + assert.NotContains(t, inputs, "projectV2Ids") + })) + http.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLQuery(` + { "data": { "add_000": { "item": { + "id": "1" + } } } } + `, func(mutations string, inputs map[string]interface{}) { + variables, err := json.Marshal(inputs) + assert.NoError(t, err) + expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}" + expectedVariables := `{"input_000":{"contentId":"Issue#1","projectId":"ROADMAPV2ID"}}` + assert.Equal(t, expectedMutations, mutations) + assert.Equal(t, expectedVariables, string(variables)) + })) + + output, err := runCommand(http, true, `-t TITLE -b BODY -p roadmapv2`, nil) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) +} diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 3f7cb3b84..4df0b6c8b 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -145,6 +145,7 @@ func editRun(opts *EditOptions) error { } if opts.Interactive || editable.Projects.Edited { lookupFields = append(lookupFields, "projectCards") + lookupFields = append(lookupFields, "projectItems") } if opts.Interactive || editable.Milestone.Edited { lookupFields = append(lookupFields, "milestone") @@ -159,7 +160,12 @@ func editRun(opts *EditOptions) error { editable.Body.Default = issue.Body editable.Assignees.Default = issue.Assignees.Logins() editable.Labels.Default = issue.Labels.Names() - editable.Projects.Default = issue.ProjectCards.ProjectNames() + editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...) + projectItems := map[string]string{} + for _, n := range issue.ProjectItems.Nodes { + projectItems[n.Project.ID] = n.ID + } + editable.Projects.ProjectItems = projectItems if issue.Milestone != nil { editable.Milestone.Default = issue.Milestone.Title } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index a43b3ae19..e378ef6b3 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -165,9 +165,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: prShared.Editable{ - Projects: prShared.EditableSlice{ - Add: []string{"Cleanup", "Roadmap"}, - Edited: true, + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Cleanup", "Roadmap"}, + Edited: true, + }, }, }, }, @@ -179,9 +181,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: prShared.Editable{ - Projects: prShared.EditableSlice{ - Remove: []string{"Cleanup", "Roadmap"}, - Edited: true, + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Remove: []string{"Cleanup", "Roadmap"}, + Edited: true, + }, }, }, }, @@ -278,10 +282,12 @@ func Test_editRun(t *testing.T) { Remove: []string{"docs"}, Edited: true, }, - Projects: prShared.EditableSlice{ - Add: []string{"Cleanup", "Roadmap"}, - Remove: []string{"Features"}, - Edited: true, + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Cleanup", "RoadmapV2"}, + Remove: []string{"Roadmap", "CleanupV2"}, + Edited: true, + }, }, Milestone: prShared.EditableString{ Value: "GA", @@ -297,9 +303,11 @@ func Test_editRun(t *testing.T) { }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockIssueGet(t, reg) + mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) mockIssueUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, @@ -322,7 +330,9 @@ func Test_editRun(t *testing.T) { eo.Body.Value = "new body" eo.Assignees.Value = []string{"monalisa", "hubot"} eo.Labels.Value = []string{"feature", "TODO", "bug"} - eo.Projects.Value = []string{"Cleanup", "Roadmap"} + eo.Labels.Add = []string{"feature", "TODO", "bug"} + eo.Labels.Remove = []string{"docs"} + eo.Projects.Value = []string{"Cleanup", "RoadmapV2"} eo.Milestone.Value = "GA" return nil }, @@ -331,8 +341,11 @@ func Test_editRun(t *testing.T) { }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockIssueGet(t, reg) + mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) + mockIssueUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, @@ -369,7 +382,31 @@ func mockIssueGet(_ *testing.T, reg *httpmock.Registry) { httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, - "url": "https://github.com/OWNER/REPO/issue/123" + "url": "https://github.com/OWNER/REPO/issue/123", + "labels": { + "nodes": [ + { "id": "DOCSID", "name": "docs" } + ], "totalCount": 1 + }, + "projectCards": { + "nodes": [ + { "project": { "name": "Roadmap" } } + ], "totalCount": 1 + } + } } } }`), + ) +} + +func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueProjectItems\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { + "projectItems": { + "nodes": [ + { "id": "ITEMID", "project": { "title": "CleanupV2" } } + ] + } } } } }`), ) } @@ -431,6 +468,27 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { "pageInfo": { "hasNextPage": false } } } } } `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) } func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) { @@ -456,3 +514,12 @@ func mockIssueUpdateLabels(t *testing.T, reg *httpmock.Registry) { func(inputs map[string]interface{}) {}), ) } + +func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLMutation(` + { "data": { "add_000": { "item": { "id": "1" } }, "delete_001": { "item": { "id": "2" } } } }`, + func(inputs map[string]interface{}) {}), + ) +} diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 0b766292b..3c1d2fdf4 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -97,6 +97,13 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f fieldSet.Remove("stateReason") } } + + var getProjectItems bool + if fieldSet.Contains("projectItems") { + getProjectItems = true + fieldSet.Remove("projectItems") + } + fields = fieldSet.ToSlice() type response struct { @@ -151,5 +158,13 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f return nil, errors.New("issue was not found but GraphQL reported no error") } + if getProjectItems { + apiClient := api.NewClientFromHTTP(httpClient) + err := api.ProjectsV2ItemsForIssue(apiClient, repo, resp.Repository.Issue) + if err != nil && !api.ProjectsV2IgnorableError(err) { + return nil, err + } + } + return resp.Repository.Issue, nil } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 26e2a71d0..65a42ec89 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -280,6 +280,94 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, + { + name: "project v2", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.Projects = []string{"RoadmapV2"} + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.StubRepoResponse("OWNER", "REPO") + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + 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": [], "pageInfo": { "hasNextPage": false } } } } }`)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "id": "PullRequest#1", + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + assert.Equal(t, false, input["draft"].(bool)) + })) + reg.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLQuery(` + { "data": { "add_000": { "item": { + "id": "1" + } } } } + `, func(mutations string, inputs map[string]interface{}) { + variables, err := json.Marshal(inputs) + assert.NoError(t, err) + expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}" + expectedVariables := `{"input_000":{"contentId":"PullRequest#1","projectId":"ROADMAPV2ID"}}` + assert.Equal(t, expectedMutations, mutations) + assert.Equal(t, expectedVariables, string(variables)) + })) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "") + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(p, _ string, opts []string) (int, error) { + if p == "Where should we push the 'feature' branch?" { + return 0, nil + } else { + return -1, prompter.NoSuchPromptErr(p) + } + } + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", + }, { name: "no maintainer modify", tty: true, @@ -575,6 +663,17 @@ func Test_createRun(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), httpmock.StringResponse(` @@ -583,6 +682,14 @@ func Test_createRun(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` @@ -696,6 +803,16 @@ func Test_createRun(t *testing.T) { ], "pageInfo": { "hasNextPage": false } } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } } `)) reg.Register( httpmock.GraphQL(`query OrganizationProjectList\b`), @@ -706,6 +823,16 @@ func Test_createRun(t *testing.T) { ], "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 } + } } } } `)) }, cmdStubs: func(cs *run.CommandStubber) { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index bad2cfa8f..3a65f7c1d 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -151,7 +151,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"}, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"}, } pr, repo, err := opts.Finder.Find(findOptions) if err != nil { @@ -166,7 +166,12 @@ func editRun(opts *EditOptions) error { editable.Reviewers.Default = pr.ReviewRequests.Logins() editable.Assignees.Default = pr.Assignees.Logins() editable.Labels.Default = pr.Labels.Names() - editable.Projects.Default = pr.ProjectCards.ProjectNames() + editable.Projects.Default = append(pr.ProjectCards.ProjectNames(), pr.ProjectItems.ProjectTitles()...) + projectItems := map[string]string{} + for _, n := range pr.ProjectItems.Nodes { + projectItems[n.Project.ID] = n.ID + } + editable.Projects.ProjectItems = projectItems if pr.Milestone != nil { editable.Milestone.Default = pr.Milestone.Title } diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index fb7c00932..1eccd53bf 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -216,9 +216,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: shared.Editable{ - Projects: shared.EditableSlice{ - Add: []string{"Cleanup", "Roadmap"}, - Edited: true, + Projects: shared.EditableProjects{ + EditableSlice: shared.EditableSlice{ + Add: []string{"Cleanup", "Roadmap"}, + Edited: true, + }, }, }, }, @@ -230,9 +232,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: shared.Editable{ - Projects: shared.EditableSlice{ - Remove: []string{"Cleanup", "Roadmap"}, - Edited: true, + Projects: shared.EditableProjects{ + EditableSlice: shared.EditableSlice{ + Remove: []string{"Cleanup", "Roadmap"}, + Edited: true, + }, }, }, }, @@ -341,10 +345,12 @@ func Test_editRun(t *testing.T) { Remove: []string{"docs"}, Edited: true, }, - Projects: shared.EditableSlice{ - Add: []string{"Cleanup", "Roadmap"}, - Remove: []string{"Features"}, - Edited: true, + Projects: shared.EditableProjects{ + EditableSlice: shared.EditableSlice{ + Add: []string{"Cleanup", "RoadmapV2"}, + Remove: []string{"CleanupV2", "Roadmap"}, + Edited: true, + }, }, Milestone: shared.EditableString{ Value: "GA", @@ -358,6 +364,7 @@ func Test_editRun(t *testing.T) { mockPullRequestUpdate(t, reg) mockPullRequestReviewersUpdate(t, reg) mockPullRequestUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, @@ -392,10 +399,12 @@ func Test_editRun(t *testing.T) { Remove: []string{"docs"}, Edited: true, }, - Projects: shared.EditableSlice{ - Value: []string{"Cleanup", "Roadmap"}, - Remove: []string{"Features"}, - Edited: true, + Projects: shared.EditableProjects{ + EditableSlice: shared.EditableSlice{ + Add: []string{"Cleanup", "RoadmapV2"}, + Remove: []string{"CleanupV2", "Roadmap"}, + Edited: true, + }, }, Milestone: shared.EditableString{ Value: "GA", @@ -408,6 +417,7 @@ func Test_editRun(t *testing.T) { mockRepoMetadata(t, reg, true) mockPullRequestUpdate(t, reg) mockPullRequestUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, @@ -427,6 +437,8 @@ func Test_editRun(t *testing.T) { mockRepoMetadata(t, reg, false) mockPullRequestUpdate(t, reg) mockPullRequestReviewersUpdate(t, reg) + mockPullRequestUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, @@ -445,6 +457,8 @@ func Test_editRun(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockRepoMetadata(t, reg, true) mockPullRequestUpdate(t, reg) + mockPullRequestUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, @@ -465,6 +479,7 @@ func Test_editRun(t *testing.T) { tt.input.HttpClient = httpClient t.Run(tt.name, func(t *testing.T) { + fmt.Println(tt.name) err := editRun(tt.input) assert.NoError(t, err) assert.Equal(t, tt.stdout, stdout.String()) @@ -530,6 +545,27 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) "pageInfo": { "hasNextPage": false } } } } } `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) if !skipReviewers { reg.Register( httpmock.GraphQL(`query OrganizationTeamList\b`), @@ -577,6 +613,15 @@ func mockPullRequestUpdateLabels(t *testing.T, reg *httpmock.Registry) { ) } +func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateProjectV2Items\b`), + httpmock.GraphQLMutation(` + { "data": { "add_000": { "item": { "id": "1" } }, "delete_001": { "item": { "id": "2" } } } }`, + func(inputs map[string]interface{}) {}), + ) +} + type testFetcher struct{} type testSurveyor struct { skipReviewers bool @@ -608,7 +653,11 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { } e.Assignees.Value = []string{"monalisa", "hubot"} e.Labels.Value = []string{"feature", "TODO", "bug"} - e.Projects.Value = []string{"Cleanup", "Roadmap"} + e.Labels.Add = []string{"feature", "TODO", "bug"} + e.Labels.Remove = []string{"docs"} + e.Projects.Value = []string{"Cleanup", "RoadmapV2"} + e.Projects.Add = []string{"Cleanup", "RoadmapV2"} + e.Projects.Remove = []string{"CleanupV2", "Roadmap"} e.Milestone.Value = "GA" return nil } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 57b042937..225dad484 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -18,7 +18,7 @@ type Editable struct { Reviewers EditableSlice Assignees EditableSlice Labels EditableSlice - Projects EditableSlice + Projects EditableProjects Milestone EditableString Metadata api.RepoMetadataResult } @@ -40,6 +40,13 @@ type EditableSlice struct { Allowed bool } +// ProjectsV2 mutations require a mapping of an item ID to a project ID. +// Keep that map along with standard EditableSlice data. +type EditableProjects struct { + EditableSlice + ProjectItems map[string]string +} + func (e Editable) Dirty() bool { return e.Title.Edited || e.Body.Edited || @@ -120,6 +127,7 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str return &a, err } +// ProjectIds returns a slice containing IDs of projects v1 that the issue or a PR has to be linked to. func (e Editable) ProjectIds() (*[]string, error) { if !e.Projects.Edited { return nil, nil @@ -131,10 +139,53 @@ func (e Editable) ProjectIds() (*[]string, error) { s.RemoveValues(e.Projects.Remove) e.Projects.Value = s.ToSlice() } - p, err := e.Metadata.ProjectsToIDs(e.Projects.Value) + p, _, err := e.Metadata.ProjectsToIDs(e.Projects.Value) return &p, err } +// ProjectV2Ids returns a pair of slices. +// The first is the projects the item should be added to. +// The second is the projects the items should be removed from. +func (e Editable) ProjectV2Ids() (*[]string, *[]string, error) { + if !e.Projects.Edited { + return nil, nil, nil + } + + // titles of projects to add + addTitles := set.NewStringSet() + addTitles.AddValues(e.Projects.Value) + addTitles.AddValues(e.Projects.Add) + addTitles.RemoveValues(e.Projects.Default) + addTitles.RemoveValues(e.Projects.Remove) + + // titles of projects to remove + removeTitles := set.NewStringSet() + removeTitles.AddValues(e.Projects.Default) + removeTitles.AddValues(e.Projects.Remove) + removeTitles.RemoveValues(e.Projects.Value) + removeTitles.RemoveValues(e.Projects.Add) + + var addIds []string + var removeIds []string + var err error + + if addTitles.Len() > 0 { + _, addIds, err = e.Metadata.ProjectsToIDs(addTitles.ToSlice()) + if err != nil { + return nil, nil, err + } + } + + if removeTitles.Len() > 0 { + _, removeIds, err = e.Metadata.ProjectsToIDs(removeTitles.ToSlice()) + if err != nil { + return nil, nil, err + } + } + + return &addIds, &removeIds, nil +} + func (e Editable) MilestoneId() (*string, error) { if !e.Milestone.Edited { return nil, nil @@ -285,8 +336,11 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) labels = append(labels, l.Name) } var projects []string - for _, l := range metadata.Projects { - projects = append(projects, l.Name) + for _, p := range metadata.Projects { + projects = append(projects, p.Name) + } + for _, p := range metadata.ProjectsV2 { + projects = append(projects, p.Title) } milestones := []string{noMilestone} for _, m := range metadata.Milestones { diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index 07bcb9a7f..3353cd5f5 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -35,6 +35,29 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR } } + // updateIssue mutation does not support ProjectsV2 so do them in a seperate request. + if options.Projects.Edited { + wg.Go(func() error { + apiClient := api.NewClientFromHTTP(httpClient) + addIds, removeIds, err := options.ProjectV2Ids() + if err != nil { + return err + } + if addIds == nil && removeIds == nil { + return nil + } + toAdd := make(map[string]string, len(*addIds)) + toRemove := make(map[string]string, len(*removeIds)) + for _, p := range *addIds { + toAdd[p] = id + } + for _, p := range *removeIds { + toRemove[p] = options.Projects.ProjectItems[p] + } + return api.UpdateProjectV2Items(apiClient, repo, toAdd, toRemove) + }) + } + if dirtyExcludingLabels(options) { wg.Go(func() error { return replaceIssueFields(httpClient, repo, id, isPR, options) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 368134871..f1886b3fe 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -139,7 +139,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields := set.NewStringSet() fields.AddValues(opts.Fields) numberFieldOnly := fields.Len() == 1 && fields.Contains("number") - fields.Add("id") // for additional preload queries below + fields.AddValues([]string{"id", "number"}) // for additional preload queries below if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) @@ -154,6 +154,12 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } } + var getProjectItems bool + if fields.Contains("projectItems") { + getProjectItems = true + fields.Remove("projectItems") + } + var pr *api.PullRequest if f.prNumber > 0 { if numberFieldOnly { @@ -184,6 +190,16 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return preloadPrChecks(httpClient, f.repo, pr) }) } + if getProjectItems { + g.Go(func() error { + apiClient := api.NewClientFromHTTP(httpClient) + err := api.ProjectsV2ItemsForPullRequest(apiClient, f.repo, pr) + if err != nil && !api.ProjectsV2IgnorableError(err) { + return err + } + return nil + }) + } return pr, f.repo, g.Wait() } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 5b13cf681..1b82c33f5 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -109,11 +109,12 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } params["labelIds"] = labelIDs - projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) + projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) if err != nil { return fmt.Errorf("could not add to project: %w", err) } params["projectIds"] = projectIDs + params["projectV2Ids"] = projectV2IDs if len(tb.Milestones) > 0 { milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0]) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index d764e2680..8582373b0 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -203,8 +203,11 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher labels = append(labels, l.Name) } var projects []string - for _, l := range metadataResult.Projects { - projects = append(projects, l.Name) + for _, p := range metadataResult.Projects { + projects = append(projects, p.Name) + } + for _, p := range metadataResult.ProjectsV2 { + projects = append(projects, p.Title) } milestones := []string{noMilestone} for _, m := range metadataResult.Milestones {