diff --git a/api/queries_issue.go b/api/queries_issue.go index 24e0b4f4c29..1a8e082ad8f 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -166,7 +166,8 @@ type ProjectCards struct { } type ProjectItems struct { - Nodes []*ProjectV2Item + Nodes []*ProjectV2Item + TotalCount int } type ProjectInfo struct { diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index f1f07af7a08..e55127f029d 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -82,8 +82,9 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue Repository struct { Issue struct { ProjectItems struct { - Nodes []*projectV2Item - PageInfo struct { + TotalCount int + Nodes []*projectV2Item + PageInfo struct { HasNextPage bool EndCursor string } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 3b02a3f2dc7..41c01ef40d9 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -124,6 +124,7 @@ func viewRun(opts *ViewOptions) error { opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) } + lookupFields.Add("projectItems") projectsV1Support := opts.Detector.ProjectsV1() if projectsV1Support == gh.ProjectsV1Supported { lookupFields.Add("projectCards") @@ -310,11 +311,24 @@ func issueAssigneeList(issue api.Issue) string { } func issueProjectList(issue api.Issue) string { - if len(issue.ProjectCards.Nodes) == 0 { + totalCount := issue.ProjectCards.TotalCount + issue.ProjectItems.TotalCount + count := len(issue.ProjectCards.Nodes) + len(issue.ProjectItems.Nodes) + + if count == 0 { return "" } - projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) + projectNames := make([]string, 0, count) + + for _, project := range issue.ProjectItems.Nodes { + colName := project.Status.Name + if colName == "" { + colName = "No Status" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Title, colName)) + } + + // TODO: Remove v1 classic project logic when completely deprecated for _, project := range issue.ProjectCards.Nodes { colName := project.Column.Name if colName == "" { @@ -324,7 +338,7 @@ func issueProjectList(issue api.Issue) string { } list := strings.Join(projectNames, ", ") - if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { + if totalCount > count { list += ", …" } return list diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 71b0884a1d8..aa6002563e8 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,7 +2,6 @@ package view import ( "bytes" - "fmt" "io" "net/http" "testing" @@ -137,11 +136,14 @@ func TestIssueView_web(t *testing.T) { func TestIssueView_nontty_Preview(t *testing.T) { tests := map[string]struct { - fixture string + httpStubs func(*httpmock.Registry) expectedOutputs []string }{ "Open issue without metadata": { - fixture: "./fixtures/issueView_preview.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tOPEN`, @@ -153,7 +155,10 @@ func TestIssueView_nontty_Preview(t *testing.T) { }, }, "Open issue with metadata": { - fixture: "./fixtures/issueView_previewWithMetadata.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json")) + mockV2ProjectItems(t, r) + }, expectedOutputs: []string{ `title:\tix of coins`, `assignees:\tmarseilles, monaco`, @@ -161,14 +166,17 @@ func TestIssueView_nontty_Preview(t *testing.T) { `state:\tOPEN`, `comments:\t9`, `labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `projects:\tv2 Project 1 \(No Status\), v2 Project 2 \(Done\), Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, `milestone:\tuluru\n`, `number:\t123\n`, `\*\*bold story\*\*`, }, }, "Open issue with empty body": { - fixture: "./fixtures/issueView_previewWithEmptyBody.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tOPEN`, @@ -178,7 +186,10 @@ func TestIssueView_nontty_Preview(t *testing.T) { }, }, "Closed issue": { - fixture: "./fixtures/issueView_previewClosedState.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `title:\tix of coins`, `state:\tCLOSED`, @@ -194,8 +205,9 @@ func TestIssueView_nontty_Preview(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + if tc.httpStubs != nil { + tc.httpStubs(http) + } output, err := runCommand(http, false, "123") if err != nil { @@ -212,11 +224,14 @@ func TestIssueView_nontty_Preview(t *testing.T) { func TestIssueView_tty_Preview(t *testing.T) { tests := map[string]struct { - fixture string + httpStubs func(*httpmock.Registry) expectedOutputs []string }{ "Open issue without metadata": { - fixture: "./fixtures/issueView_preview.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, @@ -225,21 +240,27 @@ func TestIssueView_tty_Preview(t *testing.T) { }, }, "Open issue with metadata": { - fixture: "./fixtures/issueView_previewWithMetadata.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json")) + mockV2ProjectItems(t, r) + }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`, `Assignees:.*marseilles, monaco\n`, `Labels:.*Closed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `Projects:.*v2 Project 1 \(No Status\), v2 Project 2 \(Done\), Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, `Milestone:.*uluru\n`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Open issue with empty body": { - fixture: "./fixtures/issueView_previewWithEmptyBody.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Open.*marseilles opened about 9 years ago.*9 comments`, @@ -248,7 +269,10 @@ func TestIssueView_tty_Preview(t *testing.T) { }, }, "Closed issue": { - fixture: "./fixtures/issueView_previewClosedState.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json")) + mockEmptyV2ProjectItems(t, r) + }, expectedOutputs: []string{ `ix of coins OWNER/REPO#123`, `Closed.*marseilles opened about 9 years ago.*9 comments`, @@ -266,8 +290,9 @@ func TestIssueView_tty_Preview(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) - - httpReg.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + if tc.httpStubs != nil { + tc.httpStubs(httpReg) + } opts := ViewOptions{ IO: ios, @@ -354,14 +379,15 @@ func TestIssueView_disabledIssues(t *testing.T) { func TestIssueView_tty_Comments(t *testing.T) { tests := map[string]struct { cli string - fixtures map[string]string + httpStubs func(*httpmock.Registry) expectedOutputs []string wantsErr bool }{ "without comments flag": { cli: "123", - fixtures: map[string]string{ - "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) + mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `some title OWNER/REPO#123`, @@ -375,9 +401,10 @@ func TestIssueView_tty_Comments(t *testing.T) { }, "with comments flag": { cli: "123 --comments", - fixtures: map[string]string{ - "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", - "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) + r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json")) + mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `some title OWNER/REPO#123`, @@ -406,9 +433,8 @@ func TestIssueView_tty_Comments(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tc.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + if tc.httpStubs != nil { + tc.httpStubs(http) } output, err := runCommand(http, true, tc.cli) if tc.wantsErr { @@ -426,14 +452,15 @@ func TestIssueView_tty_Comments(t *testing.T) { func TestIssueView_nontty_Comments(t *testing.T) { tests := map[string]struct { cli string - fixtures map[string]string + httpStubs func(*httpmock.Registry) expectedOutputs []string wantsErr bool }{ "without comments flag": { cli: "123", - fixtures: map[string]string{ - "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) + mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `title:\tsome title`, @@ -446,9 +473,10 @@ func TestIssueView_nontty_Comments(t *testing.T) { }, "with comments flag": { cli: "123 --comments", - fixtures: map[string]string{ - "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", - "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + httpStubs: func(r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json")) + r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json")) + mockEmptyV2ProjectItems(t, r) }, expectedOutputs: []string{ `author:\tmonalisa`, @@ -482,9 +510,8 @@ func TestIssueView_nontty_Comments(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - for name, file := range tc.fixtures { - name := fmt.Sprintf(`query %s\b`, name) - http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + if tc.httpStubs != nil { + tc.httpStubs(http) } output, err := runCommand(http, false, tc.cli) if tc.wantsErr { @@ -561,3 +588,50 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) } + +// mockEmptyV2ProjectItems registers GraphQL queries to report an issue is not contained on any v2 projects. +func mockEmptyV2ProjectItems(t *testing.T, r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(` + { "data": { "repository": { "issue": { + "projectItems": { + "totalCount": 0, + "nodes": [] + } } } } } + `)) +} + +// mockV2ProjectItems registers GraphQL queries to report an issue on multiple v2 projects in various states +// - `NO_STATUS_ITEM`: emulates this issue is on a project but is not given a status +// - `DONE_STATUS_ITEM`: emulates this issue is on a project and considered done +func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) { + r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(` + { "data": { "repository": { "issue": { + "projectItems": { + "totalCount": 2, + "nodes": [ + { + "id": "NO_STATUS_ITEM", + "project": { + "id": "PROJECT1", + "title": "v2 Project 1" + }, + "status": { + "optionId": "", + "name": "" + } + }, + { + "id": "DONE_STATUS_ITEM", + "project": { + "id": "PROJECT2", + "title": "v2 Project 2" + }, + "status": { + "optionId": "PROJECTITEMFIELD1", + "name": "Done" + } + } + ] + } } } } } + `)) +}