Skip to content

Commit 7851c9c

Browse files
committed
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.
1 parent 3bafd88 commit 7851c9c

4 files changed

Lines changed: 131 additions & 41 deletions

File tree

api/queries_issue.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ type ProjectCards struct {
166166
}
167167

168168
type ProjectItems struct {
169-
Nodes []*ProjectV2Item
169+
Nodes []*ProjectV2Item
170+
TotalCount int
170171
}
171172

172173
type ProjectInfo struct {

api/queries_projects_v2.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue
8282
Repository struct {
8383
Issue struct {
8484
ProjectItems struct {
85-
Nodes []*projectV2Item
86-
PageInfo struct {
85+
TotalCount int
86+
Nodes []*projectV2Item
87+
PageInfo struct {
8788
HasNextPage bool
8889
EndCursor string
8990
}

pkg/cmd/issue/view/view.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func viewRun(opts *ViewOptions) error {
124124
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
125125
}
126126

127+
lookupFields.Add("projectItems")
127128
projectsV1Support := opts.Detector.ProjectsV1()
128129
if projectsV1Support == gh.ProjectsV1Supported {
129130
lookupFields.Add("projectCards")
@@ -310,11 +311,24 @@ func issueAssigneeList(issue api.Issue) string {
310311
}
311312

312313
func issueProjectList(issue api.Issue) string {
313-
if len(issue.ProjectCards.Nodes) == 0 {
314+
totalCount := issue.ProjectCards.TotalCount + issue.ProjectItems.TotalCount
315+
count := len(issue.ProjectCards.Nodes) + len(issue.ProjectItems.Nodes)
316+
317+
if count == 0 {
314318
return ""
315319
}
316320

317-
projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
321+
projectNames := make([]string, 0, count)
322+
323+
for _, project := range issue.ProjectItems.Nodes {
324+
colName := project.Status.Name
325+
if colName == "" {
326+
colName = "No Status"
327+
}
328+
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Title, colName))
329+
}
330+
331+
// TODO: Remove v1 classic project logic when completely deprecated
318332
for _, project := range issue.ProjectCards.Nodes {
319333
colName := project.Column.Name
320334
if colName == "" {
@@ -324,7 +338,7 @@ func issueProjectList(issue api.Issue) string {
324338
}
325339

326340
list := strings.Join(projectNames, ", ")
327-
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
341+
if totalCount > count {
328342
list += ", …"
329343
}
330344
return list

pkg/cmd/issue/view/view_test.go

Lines changed: 109 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package view
22

33
import (
44
"bytes"
5-
"fmt"
65
"io"
76
"net/http"
87
"testing"
@@ -137,11 +136,14 @@ func TestIssueView_web(t *testing.T) {
137136

138137
func TestIssueView_nontty_Preview(t *testing.T) {
139138
tests := map[string]struct {
140-
fixture string
139+
httpStubs func(*httpmock.Registry)
141140
expectedOutputs []string
142141
}{
143142
"Open issue without metadata": {
144-
fixture: "./fixtures/issueView_preview.json",
143+
httpStubs: func(r *httpmock.Registry) {
144+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json"))
145+
mockEmptyV2ProjectItems(t, r)
146+
},
145147
expectedOutputs: []string{
146148
`title:\tix of coins`,
147149
`state:\tOPEN`,
@@ -153,22 +155,28 @@ func TestIssueView_nontty_Preview(t *testing.T) {
153155
},
154156
},
155157
"Open issue with metadata": {
156-
fixture: "./fixtures/issueView_previewWithMetadata.json",
158+
httpStubs: func(r *httpmock.Registry) {
159+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json"))
160+
mockV2ProjectItems(t, r)
161+
},
157162
expectedOutputs: []string{
158163
`title:\tix of coins`,
159164
`assignees:\tmarseilles, monaco`,
160165
`author:\tmarseilles`,
161166
`state:\tOPEN`,
162167
`comments:\t9`,
163168
`labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug`,
164-
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
169+
`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`,
165170
`milestone:\tuluru\n`,
166171
`number:\t123\n`,
167172
`\*\*bold story\*\*`,
168173
},
169174
},
170175
"Open issue with empty body": {
171-
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
176+
httpStubs: func(r *httpmock.Registry) {
177+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json"))
178+
mockEmptyV2ProjectItems(t, r)
179+
},
172180
expectedOutputs: []string{
173181
`title:\tix of coins`,
174182
`state:\tOPEN`,
@@ -178,7 +186,10 @@ func TestIssueView_nontty_Preview(t *testing.T) {
178186
},
179187
},
180188
"Closed issue": {
181-
fixture: "./fixtures/issueView_previewClosedState.json",
189+
httpStubs: func(r *httpmock.Registry) {
190+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json"))
191+
mockEmptyV2ProjectItems(t, r)
192+
},
182193
expectedOutputs: []string{
183194
`title:\tix of coins`,
184195
`state:\tCLOSED`,
@@ -194,8 +205,9 @@ func TestIssueView_nontty_Preview(t *testing.T) {
194205
t.Run(name, func(t *testing.T) {
195206
http := &httpmock.Registry{}
196207
defer http.Verify(t)
197-
198-
http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
208+
if tc.httpStubs != nil {
209+
tc.httpStubs(http)
210+
}
199211

200212
output, err := runCommand(http, false, "123")
201213
if err != nil {
@@ -212,11 +224,14 @@ func TestIssueView_nontty_Preview(t *testing.T) {
212224

213225
func TestIssueView_tty_Preview(t *testing.T) {
214226
tests := map[string]struct {
215-
fixture string
227+
httpStubs func(*httpmock.Registry)
216228
expectedOutputs []string
217229
}{
218230
"Open issue without metadata": {
219-
fixture: "./fixtures/issueView_preview.json",
231+
httpStubs: func(r *httpmock.Registry) {
232+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_preview.json"))
233+
mockEmptyV2ProjectItems(t, r)
234+
},
220235
expectedOutputs: []string{
221236
`ix of coins OWNER/REPO#123`,
222237
`Open.*marseilles opened about 9 years ago.*9 comments`,
@@ -225,21 +240,27 @@ func TestIssueView_tty_Preview(t *testing.T) {
225240
},
226241
},
227242
"Open issue with metadata": {
228-
fixture: "./fixtures/issueView_previewWithMetadata.json",
243+
httpStubs: func(r *httpmock.Registry) {
244+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithMetadata.json"))
245+
mockV2ProjectItems(t, r)
246+
},
229247
expectedOutputs: []string{
230248
`ix of coins OWNER/REPO#123`,
231249
`Open.*marseilles opened about 9 years ago.*9 comments`,
232250
`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}`,
233251
`Assignees:.*marseilles, monaco\n`,
234252
`Labels:.*Closed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`,
235-
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
253+
`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`,
236254
`Milestone:.*uluru\n`,
237255
`bold story`,
238256
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
239257
},
240258
},
241259
"Open issue with empty body": {
242-
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
260+
httpStubs: func(r *httpmock.Registry) {
261+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewWithEmptyBody.json"))
262+
mockEmptyV2ProjectItems(t, r)
263+
},
243264
expectedOutputs: []string{
244265
`ix of coins OWNER/REPO#123`,
245266
`Open.*marseilles opened about 9 years ago.*9 comments`,
@@ -248,7 +269,10 @@ func TestIssueView_tty_Preview(t *testing.T) {
248269
},
249270
},
250271
"Closed issue": {
251-
fixture: "./fixtures/issueView_previewClosedState.json",
272+
httpStubs: func(r *httpmock.Registry) {
273+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewClosedState.json"))
274+
mockEmptyV2ProjectItems(t, r)
275+
},
252276
expectedOutputs: []string{
253277
`ix of coins OWNER/REPO#123`,
254278
`Closed.*marseilles opened about 9 years ago.*9 comments`,
@@ -266,8 +290,9 @@ func TestIssueView_tty_Preview(t *testing.T) {
266290

267291
httpReg := &httpmock.Registry{}
268292
defer httpReg.Verify(t)
269-
270-
httpReg.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
293+
if tc.httpStubs != nil {
294+
tc.httpStubs(httpReg)
295+
}
271296

272297
opts := ViewOptions{
273298
IO: ios,
@@ -354,14 +379,15 @@ func TestIssueView_disabledIssues(t *testing.T) {
354379
func TestIssueView_tty_Comments(t *testing.T) {
355380
tests := map[string]struct {
356381
cli string
357-
fixtures map[string]string
382+
httpStubs func(*httpmock.Registry)
358383
expectedOutputs []string
359384
wantsErr bool
360385
}{
361386
"without comments flag": {
362387
cli: "123",
363-
fixtures: map[string]string{
364-
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
388+
httpStubs: func(r *httpmock.Registry) {
389+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json"))
390+
mockEmptyV2ProjectItems(t, r)
365391
},
366392
expectedOutputs: []string{
367393
`some title OWNER/REPO#123`,
@@ -375,9 +401,10 @@ func TestIssueView_tty_Comments(t *testing.T) {
375401
},
376402
"with comments flag": {
377403
cli: "123 --comments",
378-
fixtures: map[string]string{
379-
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
380-
"CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
404+
httpStubs: func(r *httpmock.Registry) {
405+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json"))
406+
r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json"))
407+
mockEmptyV2ProjectItems(t, r)
381408
},
382409
expectedOutputs: []string{
383410
`some title OWNER/REPO#123`,
@@ -406,9 +433,8 @@ func TestIssueView_tty_Comments(t *testing.T) {
406433
t.Run(name, func(t *testing.T) {
407434
http := &httpmock.Registry{}
408435
defer http.Verify(t)
409-
for name, file := range tc.fixtures {
410-
name := fmt.Sprintf(`query %s\b`, name)
411-
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
436+
if tc.httpStubs != nil {
437+
tc.httpStubs(http)
412438
}
413439
output, err := runCommand(http, true, tc.cli)
414440
if tc.wantsErr {
@@ -426,14 +452,15 @@ func TestIssueView_tty_Comments(t *testing.T) {
426452
func TestIssueView_nontty_Comments(t *testing.T) {
427453
tests := map[string]struct {
428454
cli string
429-
fixtures map[string]string
455+
httpStubs func(*httpmock.Registry)
430456
expectedOutputs []string
431457
wantsErr bool
432458
}{
433459
"without comments flag": {
434460
cli: "123",
435-
fixtures: map[string]string{
436-
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
461+
httpStubs: func(r *httpmock.Registry) {
462+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json"))
463+
mockEmptyV2ProjectItems(t, r)
437464
},
438465
expectedOutputs: []string{
439466
`title:\tsome title`,
@@ -446,9 +473,10 @@ func TestIssueView_nontty_Comments(t *testing.T) {
446473
},
447474
"with comments flag": {
448475
cli: "123 --comments",
449-
fixtures: map[string]string{
450-
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
451-
"CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
476+
httpStubs: func(r *httpmock.Registry) {
477+
r.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse("./fixtures/issueView_previewSingleComment.json"))
478+
r.Register(httpmock.GraphQL(`query CommentsForIssue\b`), httpmock.FileResponse("./fixtures/issueView_previewFullComments.json"))
479+
mockEmptyV2ProjectItems(t, r)
452480
},
453481
expectedOutputs: []string{
454482
`author:\tmonalisa`,
@@ -482,9 +510,8 @@ func TestIssueView_nontty_Comments(t *testing.T) {
482510
t.Run(name, func(t *testing.T) {
483511
http := &httpmock.Registry{}
484512
defer http.Verify(t)
485-
for name, file := range tc.fixtures {
486-
name := fmt.Sprintf(`query %s\b`, name)
487-
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
513+
if tc.httpStubs != nil {
514+
tc.httpStubs(http)
488515
}
489516
output, err := runCommand(http, false, tc.cli)
490517
if tc.wantsErr {
@@ -561,3 +588,50 @@ func TestProjectsV1Deprecation(t *testing.T) {
561588
reg.Verify(t)
562589
})
563590
}
591+
592+
// mockEmptyV2ProjectItems registers GraphQL queries to report an issue is not contained on any v2 projects.
593+
func mockEmptyV2ProjectItems(t *testing.T, r *httpmock.Registry) {
594+
r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(`
595+
{ "data": { "repository": { "issue": {
596+
"projectItems": {
597+
"totalCount": 0,
598+
"nodes": []
599+
} } } } }
600+
`))
601+
}
602+
603+
// mockV2ProjectItems registers GraphQL queries to report an issue on multiple v2 projects in various states
604+
// - `NO_STATUS_ITEM`: emulates this issue is on a project but is not given a status
605+
// - `DONE_STATUS_ITEM`: emulates this issue is on a project and considered done
606+
func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) {
607+
r.Register(httpmock.GraphQL(`query IssueProjectItems\b`), httpmock.StringResponse(`
608+
{ "data": { "repository": { "issue": {
609+
"projectItems": {
610+
"totalCount": 2,
611+
"nodes": [
612+
{
613+
"id": "NO_STATUS_ITEM",
614+
"project": {
615+
"id": "PROJECT1",
616+
"title": "v2 Project 1"
617+
},
618+
"status": {
619+
"optionId": "",
620+
"name": ""
621+
}
622+
},
623+
{
624+
"id": "DONE_STATUS_ITEM",
625+
"project": {
626+
"id": "PROJECT2",
627+
"title": "v2 Project 2"
628+
},
629+
"status": {
630+
"optionId": "PROJECTITEMFIELD1",
631+
"name": "Done"
632+
}
633+
}
634+
]
635+
} } } } }
636+
`))
637+
}

0 commit comments

Comments
 (0)