Update gh issue view to show v2 projects
These changes enhance the existing `gh issue view` experience by listing v2 projects in interactive and non-interactive forms. Additionally, the tests have been enhanced to use a more standard `httpStubs` approach from other tests.
This commit is contained in:
parent
3bafd883b5
commit
7851c9c664
4 changed files with 131 additions and 41 deletions
|
|
@ -166,7 +166,8 @@ type ProjectCards struct {
|
|||
}
|
||||
|
||||
type ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
Nodes []*ProjectV2Item
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
type ProjectInfo struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
} } } } }
|
||||
`))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue