diff --git a/api/export_pr.go b/api/export_pr.go index 8c17ea510..bb3310811 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -23,7 +23,7 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(issue.ProjectItems.Nodes)) for _, n := range issue.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status.StatusFragment, + "status": n.Status, "title": n.Project.Title, }) } @@ -109,7 +109,7 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(pr.ProjectItems.Nodes)) for _, n := range pr.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status.StatusFragment, + "status": n.Status, "title": n.Project.Title, }) } diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 50e346dee..b7f4dcddb 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -75,6 +75,38 @@ func TestIssue_ExportData(t *testing.T) { } `), }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTI_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" + } + ] + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -181,6 +213,38 @@ func TestPullRequest_ExportData(t *testing.T) { } `), }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTPR_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" + } + ] + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -205,108 +269,3 @@ func TestPullRequest_ExportData(t *testing.T) { }) } } - -// The following tests exist separately from the table driven tests -// above because in the implementation code, the GraphQL request is -// performed by shurcool, and it handles the JSON decoding into the -// response struct, which is slightly different than the stdlib JSON -// decoder used by the rest of the requests when Finding an Issue or PR. -func TestIssueExportProjectItems(t *testing.T) { - issue := Issue{ - ProjectItems: ProjectItems{ - Nodes: []*ProjectV2Item{ - { - ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ - ID: "PVT_kwDOB-vozM4AVk16", - Title: "Test Project", - }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, - }, - }, - }, - }, - } - - expectedExportedJSON := heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - }, - "title": "Test Project" - } - ] - } - `) - - exported := issue.ExportData([]string{"projectItems"}) - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - enc.SetIndent("", "\t") - require.NoError(t, enc.Encode(exported)) - require.Equal(t, expectedExportedJSON, buf.String()) -} - -func TestPRExportProjectItems(t *testing.T) { - pr := PullRequest{ - ProjectItems: ProjectItems{ - Nodes: []*ProjectV2Item{ - { - ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ - ID: "PVT_kwDOB-vozM4AVk16", - Title: "Test Project", - }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, - }, - }, - }, - }, - } - - expectedExportedJSON := heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - }, - "title": "Test Project" - } - ] - } - `) - - exported := pr.ExportData([]string{"projectItems"}) - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - enc.SetIndent("", "\t") - require.NoError(t, enc.Encode(exported)) - require.Equal(t, expectedExportedJSON, buf.String()) -} diff --git a/api/queries_issue.go b/api/queries_issue.go index 1871294e7..62531e84e 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -102,18 +102,18 @@ type ProjectInfo struct { type ProjectV2Item struct { ID string `json:"id"` - Project struct { - ID string `json:"id"` - Title string `json:"title"` - } - Status Status `graphql:"status:fieldValueByName(name: \"Status\")"` + Project ProjectV2ItemProject + Status ProjectV2ItemStatus } -type Status struct { - StatusFragment struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` +type ProjectV2ItemProject struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type ProjectV2ItemStatus struct { + OptionID string `json:"optionId"` + Name string `json:"name"` } func (p ProjectCards) ProjectNames() []string { diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index a602653af..958be6616 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -61,11 +61,27 @@ func UpdateProjectV2Items(client *Client, repo ghrepo.Interface, addProjectItems // ProjectsV2ItemsForIssue fetches all ProjectItems for an issue. func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) error { + type projectV2ItemStatus struct { + StatusFragment struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } + + type projectV2Item struct { + ID string `json:"id"` + Project struct { + ID string `json:"id"` + Title string `json:"title"` + } + Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"` + } + type response struct { Repository struct { Issue struct { ProjectItems struct { - Nodes []*ProjectV2Item + Nodes []*projectV2Item PageInfo struct { HasNextPage bool EndCursor string @@ -87,7 +103,20 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue if err != nil { return err } - items.Nodes = append(items.Nodes, query.Repository.Issue.ProjectItems.Nodes...) + for _, projectItemNode := range query.Repository.Issue.ProjectItems.Nodes { + items.Nodes = append(items.Nodes, &ProjectV2Item{ + ID: projectItemNode.ID, + Project: ProjectV2ItemProject{ + ID: projectItemNode.Project.ID, + Title: projectItemNode.Project.Title, + }, + Status: ProjectV2ItemStatus{ + OptionID: projectItemNode.Status.StatusFragment.OptionID, + Name: projectItemNode.Status.StatusFragment.Name, + }, + }) + } + if !query.Repository.Issue.ProjectItems.PageInfo.HasNextPage { break } @@ -99,11 +128,27 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue // ProjectsV2ItemsForPullRequest fetches all ProjectItems for a pull request. func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) error { + type projectV2ItemStatus struct { + StatusFragment struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } + + type projectV2Item struct { + ID string `json:"id"` + Project struct { + ID string `json:"id"` + Title string `json:"title"` + } + Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"` + } + type response struct { Repository struct { PullRequest struct { ProjectItems struct { - Nodes []*ProjectV2Item + Nodes []*projectV2Item PageInfo struct { HasNextPage bool EndCursor string @@ -125,7 +170,21 @@ func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *Pu if err != nil { return err } - items.Nodes = append(items.Nodes, query.Repository.PullRequest.ProjectItems.Nodes...) + + for _, projectItemNode := range query.Repository.PullRequest.ProjectItems.Nodes { + items.Nodes = append(items.Nodes, &ProjectV2Item{ + ID: projectItemNode.ID, + Project: ProjectV2ItemProject{ + ID: projectItemNode.Project.ID, + Title: projectItemNode.Project.Title, + }, + Status: ProjectV2ItemStatus{ + OptionID: projectItemNode.Status.StatusFragment.OptionID, + Name: projectItemNode.Status.StatusFragment.Name, + }, + }) + } + if !query.Repository.PullRequest.ProjectItems.PageInfo.HasNextPage { break } diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index 8cacad921..fae49c926 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -229,21 +229,13 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) { Nodes: []*ProjectV2Item{ { ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ + Project: ProjectV2ItemProject{ ID: "PVT_kwDOB-vozM4AVk16", Title: "Test Project", }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, + Status: ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", }, }, }, diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index e816500c2..5bed620c1 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,6 +20,7 @@ import ( "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -456,3 +458,151 @@ func Test_issueList(t *testing.T) { }) } } + +func TestIssueList_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issues": { + "totalCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "states": []interface{}{"OPEN"}, + }, params) + })) + + client := &http.Client{Transport: reg} + issuesAndTotalCount, err := issueList( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "issue", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, issuesAndTotalCount.Issues, 1) + require.Len(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes, 1) + + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Status, expectedStatus) +} + +func TestIssueList_Search_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "hasIssuesEnabled": true + }, + "search": { + "issueCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rl0", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "type": "ISSUE", + "limit": float64(30), + "query": "just used to force the search API branch repo:OWNER/REPO type:issue", + }, params) + })) + + client := &http.Client{Transport: reg} + issuesAndTotalCount, err := issueList( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "issue", + Search: "just used to force the search API branch", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, issuesAndTotalCount.Issues, 1) + require.Len(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes, 1) + + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rl0") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Status, expectedStatus) +} diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 61a8e3698..869db9c38 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -9,15 +9,18 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -320,3 +323,146 @@ func TestPRList_web(t *testing.T) { }) } } + +func TestPRList_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "pullRequests": { + "totalCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "state": []interface{}{"OPEN"}, + }, params) + })) + + client := &http.Client{Transport: reg} + prsAndTotalCount, err := listPullRequests( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "pr", + State: "open", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, prsAndTotalCount.PullRequests, 1) + require.Len(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes, 1) + + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Status, expectedStatus) +} + +func TestPRList_Search_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query PullRequestSearch\b`), + httpmock.GraphQLQuery(`{ + "data": { + "search": { + "issueCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rl0", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "limit": float64(30), + "q": "just used to force the search API branch repo:OWNER/REPO state:open type:pr", + }, params) + })) + + client := &http.Client{Transport: reg} + prsAndTotalCount, err := listPullRequests( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "pr", + State: "open", + Search: "just used to force the search API branch", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, prsAndTotalCount.PullRequests, 1) + require.Len(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes, 1) + + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rl0") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Status, expectedStatus) +}