Add projectsV2 support to issue create, issue edit, pr create, and pr edit (#6735)

Co-authored-by: pshevche <pavel.shevchenko.95@gmail.com>
Co-authored-by: Sam Coe <samcoe@users.noreply.github.com>
This commit is contained in:
Ilya Yatsishin 2023-01-19 23:13:09 +01:00 committed by GitHub
parent 06ae07f971
commit 179e9c256d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1249 additions and 82 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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
}

144
api/queries_projects_v2.go Normal file
View file

@ -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
}

View file

@ -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()
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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":

View file

@ -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())
}

View file

@ -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
}

View file

@ -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{}) {}),
)
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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)

View file

@ -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()
}

View file

@ -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])

View file

@ -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 {