Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/queries_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ type ProjectCards struct {
}

type ProjectItems struct {
Nodes []*ProjectV2Item
Nodes []*ProjectV2Item
TotalCount int
}

type ProjectInfo struct {
Expand Down
5 changes: 3 additions & 2 deletions api/queries_projects_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 17 additions & 3 deletions pkg/cmd/issue/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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
Expand Down
144 changes: 109 additions & 35 deletions pkg/cmd/issue/view/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package view

import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
Expand Down Expand Up @@ -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`,
Expand All @@ -153,22 +155,28 @@ 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`,
`author:\tmarseilles`,
`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`,
Expand All @@ -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`,
Expand All @@ -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 {
Expand All @@ -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`,
Expand All @@ -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`,
Expand All @@ -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`,
Expand All @@ -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,
Expand Down Expand Up @@ -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`,
Expand All @@ -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`,
Expand Down Expand Up @@ -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 {
Expand All @@ -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`,
Expand All @@ -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`,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment on lines +603 to +605
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this comment 👍

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"
}
}
]
} } } } }
`))
}
Loading