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:
parent
06ae07f971
commit
179e9c256d
20 changed files with 1249 additions and 82 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
144
api/queries_projects_v2.go
Normal 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
|
||||
}
|
||||
252
api/queries_projects_v2_test.go
Normal file
252
api/queries_projects_v2_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}) {}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue