diff --git a/api/queries_issue.go b/api/queries_issue.go index 24e0b4f4c..1a8e082ad 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 f1f07af7a..68681f2e8 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 } @@ -149,8 +150,9 @@ func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *Pu Repository struct { PullRequest struct { ProjectItems struct { - Nodes []*projectV2Item - PageInfo struct { + TotalCount int + Nodes []*projectV2Item + PageInfo struct { HasNextPage bool EndCursor string } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index a2f34a60b..c61f47aeb 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -5,6 +5,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/hashicorp/go-version" "golang.org/x/sync/errgroup" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -205,12 +206,35 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) { return features, nil } +const ( + enterpriseProjectsV1Removed = "3.17.0" +) + func (d *detector) ProjectsV1() gh.ProjectsV1Support { - // Currently, projects v1 support is entirely dependent on the host. As this is deprecated in GHES, - // we will do feature detection on whether the GHES version has support. - if ghauth.IsEnterprise(d.host) { + if !ghauth.IsEnterprise(d.host) { + return gh.ProjectsV1Unsupported + } + + hostVersion, hostVersionErr := resolveEnterpriseVersion(d.httpClient, d.host) + v1ProjectCutoffVersion, v1ProjectCutoffVersionErr := version.NewVersion(enterpriseProjectsV1Removed) + + if hostVersionErr == nil && v1ProjectCutoffVersionErr == nil && hostVersion.LessThan(v1ProjectCutoffVersion) { return gh.ProjectsV1Supported } return gh.ProjectsV1Unsupported } + +func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { + var metaResponse struct { + InstalledVersion string `json:"installed_version"` + } + + apiClient := api.NewClientFromHTTP(httpClient) + err := apiClient.REST(host, "GET", "meta", nil, &metaResponse) + if err != nil { + return nil, err + } + + return version.NewVersion(metaResponse.InstalledVersion) +} diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 2c7d19071..e850546a7 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -373,17 +373,69 @@ func TestRepositoryFeatures(t *testing.T) { } func TestProjectV1Support(t *testing.T) { - t.Parallel() + tests := []struct { + name string + hostname string + httpStubs func(*httpmock.Registry) + wantFeatures gh.ProjectsV1Support + }{ + { + name: "github.com", + hostname: "github.com", + wantFeatures: gh.ProjectsV1Unsupported, + }, + { + name: "ghec data residency (ghe.com)", + hostname: "stampname.ghe.com", + wantFeatures: gh.ProjectsV1Unsupported, + }, + { + name: "GHE 3.16.0", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.16.0"}`), + ) + }, + wantFeatures: gh.ProjectsV1Supported, + }, + { + name: "GHE 3.16.1", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.16.1"}`), + ) + }, + wantFeatures: gh.ProjectsV1Supported, + }, + { + name: "GHE 3.17", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.17.0"}`), + ) + }, + wantFeatures: gh.ProjectsV1Unsupported, + }, + } - t.Run("when the host is enterprise, project v1 is supported", func(t *testing.T) { - detector := detector{host: "my.ghes.com"} - isProjectV1Supported := detector.ProjectsV1() - require.Equal(t, gh.ProjectsV1Supported, isProjectV1Supported) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) - t.Run("when the host is not enterprise, project v1 is not supported", func(t *testing.T) { - detector := detector{host: "github.com"} - isProjectV1Supported := detector.ProjectsV1() - require.Equal(t, gh.ProjectsV1Unsupported, isProjectV1Supported) - }) + detector := NewDetector(httpClient, tt.hostname) + require.Equal(t, tt.wantFeatures, detector.ProjectsV1()) + }) + } } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 3b02a3f2d..41c01ef40 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 71b0884a1..aa6002563 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" + } + } + ] + } } } } } + `)) +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json index be4a7713d..194574ba9 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json @@ -54,6 +54,33 @@ ], "totalcount": 5 }, + "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" + } + } + ] + }, "projectcards": { "nodes": [ { diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 8a39d1134..564cce913 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -85,7 +85,7 @@ var defaultFields = []string{ "url", "number", "title", "state", "body", "author", "autoMergeRequest", "isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount", "baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository", - "reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone", + "reviewRequests", "reviews", "assignees", "labels", "projectCards", "projectItems", "milestone", "comments", "reactionGroups", "createdAt", "statusCheckRollup", } @@ -439,11 +439,23 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string { } func prProjectList(pr api.PullRequest) string { - if len(pr.ProjectCards.Nodes) == 0 { + totalCount := pr.ProjectCards.TotalCount + pr.ProjectItems.TotalCount + count := len(pr.ProjectCards.Nodes) + len(pr.ProjectItems.Nodes) + + if count == 0 { return "" } projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) + + for _, project := range pr.ProjectItems.Nodes { + colName := project.Status.Name + if colName == "" { + colName = "No Status" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Title, colName)) + } + for _, project := range pr.ProjectCards.Nodes { if project == nil { continue @@ -456,7 +468,7 @@ func prProjectList(pr api.PullRequest) string { } list := strings.Join(projectNames, ", ") - if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { + if totalCount > count { list += ", …" } return list diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 35f7fa513..38b2a5047 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -286,7 +286,7 @@ func TestPRView_Preview_nontty(t *testing.T) { `reviewers:\t1 \(Requested\)\n`, `assignees:\tmarseilles, monaco\n`, `labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`, - `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`, `\*\*blueberries taste good\*\*`, }, @@ -457,7 +457,7 @@ func TestPRView_Preview(t *testing.T) { `Reviewers:.*1 \(.*Requested.*\)\n`, `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`, `blueberries taste good`, `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,