Repository: cli/cli
Pull request #10927 (2025-05-04 20:07:06)

Improve assertion for disabled echo mode
Relates to #10916

This PR is follow up to #10918 to further improve the assertion for disabled echo mode, by replacing `console.ExpectString` with `console.Expect(expect.RegExp("..."))`.

The reason for this change is that `console.ExpectString("...")` looks for the given string at any part of the unconsumed stdout stream, and successfully returns if it finds a match. So, to make sure no password/token is actually echoed we need to assert that the entire (unconsumed part of the) stream does not include the entered password/token. The solution is using `console.Expect(expect.RegExp("..."))` with an anchored regexp.

I have explained this in the tests so that we know why we're doing it this way.



Patch:
diff --git a/api/export_pr.go b/api/export_pr.go
index 7ae1a4ff4..9b030c39e 100644
--- a/api/export_pr.go
+++ b/api/export_pr.go
@@ -28,6 +28,24 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
 				})
 			}
 			data[f] = items
+		case "closedByPullRequestsReferences":
+			items := make([]map[string]interface{}, 0, len(issue.ClosedByPullRequestsReferences.Nodes))
+			for _, n := range issue.ClosedByPullRequestsReferences.Nodes {
+				items = append(items, map[string]interface{}{
+					"id":     n.ID,
+					"number": n.Number,
+					"url":    n.URL,
+					"repository": map[string]interface{}{
+						"id":   n.Repository.ID,
+						"name": n.Repository.Name,
+						"owner": map[string]interface{}{
+							"id":    n.Repository.Owner.ID,
+							"login": n.Repository.Owner.Login,
+						},
+					},
+				})
+			}
+			data[f] = items
 		default:
 			sf := fieldByName(v, f)
 			data[f] = sf.Interface()
@@ -143,7 +161,6 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
 			items := make([]map[string]interface{}, 0, len(pr.ClosingIssuesReferences.Nodes))
 			for _, n := range pr.ClosingIssuesReferences.Nodes {
 				items = append(items, map[string]interface{}{
-
 					"id":     n.ID,
 					"number": n.Number,
 					"url":    n.URL,
diff --git a/api/queries_issue.go b/api/queries_issue.go
index 094b6b198..f09360152 100644
--- a/api/queries_issue.go
+++ b/api/queries_issue.go
@@ -44,6 +44,28 @@ type Issue struct {
 	Milestone        *Milestone
 	ReactionGroups   ReactionGroups
 	IsPinned         bool
+
+	ClosedByPullRequestsReferences ClosedByPullRequestsReferences
+}
+
+type ClosedByPullRequestsReferences struct {
+	Nodes []struct {
+		ID         string
+		Number     int
+		URL        string
+		Repository struct {
+			ID    string
+			Name  string
+			Owner struct {
+				ID    string
+				Login string
+			}
+		}
+	}
+	PageInfo struct {
+		HasNextPage bool
+		EndCursor   string
+	}
 }
 
 // return values for Issue.Typename
diff --git a/api/query_builder.go b/api/query_builder.go
index 4c45da3c1..47fb4c225 100644
--- a/api/query_builder.go
+++ b/api/query_builder.go
@@ -56,6 +56,25 @@ var issueCommentLast = shortenQuery(`
 	}
 `)
 
+var issueClosedByPullRequestsReferences = shortenQuery(`
+	closedByPullRequestsReferences(first: 100) {
+		nodes {
+			id,
+			number,
+			url,
+			repository {
+				id,
+				name,
+				owner {
+					id,
+					login
+				}
+			}
+		}
+		pageInfo{hasNextPage,endCursor}
+	}
+`)
+
 var prReviewRequests = shortenQuery(`
 	reviewRequests(first: 100) {
 		nodes {
@@ -296,6 +315,7 @@ var sharedIssuePRFields = []string{
 var issueOnlyFields = []string{
 	"isPinned",
 	"stateReason",
+	"closedByPullRequestsReferences",
 }
 
 var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
@@ -388,6 +408,8 @@ func IssueGraphQL(fields []string) string {
 			q = append(q, StatusCheckRollupGraphQLWithCountByState())
 		case "closingIssuesReferences":
 			q = append(q, prClosingIssuesReferences)
+		case "closedByPullRequestsReferences":
+			q = append(q, issueClosedByPullRequestsReferences)
 		default:
 			q = append(q, field)
 		}
diff --git a/pkg/cmd/issue/view/http.go b/pkg/cmd/issue/view/http.go
index e4f756436..4adc71802 100644
--- a/pkg/cmd/issue/view/http.go
+++ b/pkg/cmd/issue/view/http.go
@@ -53,3 +53,42 @@ func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api
 	issue.Comments.PageInfo.HasNextPage = false
 	return nil
 }
+
+func preloadClosedByPullRequestsReferences(client *http.Client, repo ghrepo.Interface, issue *api.Issue) error {
+	if !issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage {
+		return nil
+	}
+
+	type response struct {
+		Node struct {
+			Issue struct {
+				ClosedByPullRequestsReferences api.ClosedByPullRequestsReferences `graphql:"closedByPullRequestsReferences(first: 100, after: $endCursor)"`
+			} `graphql:"...on Issue"`
+		} `graphql:"node(id: $id)"`
+	}
+
+	variables := map[string]interface{}{
+		"id":        githubv4.ID(issue.ID),
+		"endCursor": githubv4.String(issue.ClosedByPullRequestsReferences.PageInfo.EndCursor),
+	}
+
+	gql := api.NewClientFromHTTP(client)
+
+	for {
+		var query response
+		err := gql.Query(repo.RepoHost(), "closedByPullRequestsReferences", &query, variables)
+		if err != nil {
+			return err
+		}
+
+		issue.ClosedByPullRequestsReferences.Nodes = append(issue.ClosedByPullRequestsReferences.Nodes, query.Node.Issue.ClosedByPullRequestsReferences.Nodes...)
+
+		if !query.Node.Issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage {
+			break
+		}
+		variables["endCursor"] = githubv4.String(query.Node.Issue.ClosedByPullRequestsReferences.PageInfo.EndCursor)
+	}
+
+	issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage = false
+	return nil
+}
diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go
index a9e25513b..3b02a3f2d 100644
--- a/pkg/cmd/issue/view/view.go
+++ b/pkg/cmd/issue/view/view.go
@@ -1,7 +1,6 @@
 package view
 
 import (
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -134,6 +133,8 @@ func viewRun(opts *ViewOptions) error {
 	opts.IO.DetectTerminalTheme()
 
 	opts.IO.StartProgressIndicator()
+	defer opts.IO.StopProgressIndicator()
+
 	lookupFields.Add("id")
 
 	issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, lookupFields.ToSlice())
@@ -144,18 +145,21 @@ func viewRun(opts *ViewOptions) error {
 	if lookupFields.Contains("comments") {
 		// FIXME: this re-fetches the comments connection even though the initial set of 100 were
 		// fetched in the previous request.
-		err = preloadIssueComments(httpClient, baseRepo, issue)
+		err := preloadIssueComments(httpClient, baseRepo, issue)
+		if err != nil {
+			return err
+		}
 	}
-	opts.IO.StopProgressIndicator()
-	if err != nil {
-		var loadErr *issueShared.PartialLoadError
-		if opts.Exporter == nil && errors.As(err, &loadErr) {
-			fmt.Fprintf(opts.IO.ErrOut, "warning: %s\n", loadErr.Error())
-		} else {
+
+	if lookupFields.Contains("closedByPullRequestsReferences") {
+		err := preloadClosedByPullRequestsReferences(httpClient, baseRepo, issue)
+		if err != nil {
 			return err
 		}
 	}
 
+	opts.IO.StopProgressIndicator()
+
 	if opts.WebMode {
 		openURL := issue.URL
 		if opts.IO.IsStdoutTTY() {
diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go
index 483b8246b..1d980b68d 100644
--- a/pkg/cmd/pr/create/create.go
+++ b/pkg/cmd/pr/create/create.go
@@ -400,7 +400,7 @@ func createRun(opts *CreateOptions) error {
 		if err != nil {
 			return err
 		}
-		openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported)
+		openURL, err = generateCompareURL(*ctx, *state, projectsV1Support)
 		if err != nil {
 			return err
 		}
@@ -456,7 +456,6 @@ func createRun(opts *CreateOptions) error {
 		if err != nil {
 			return err
 		}
-		// TODO wm: revisit project support
 		return submitPR(*opts, *ctx, *state, projectsV1Support)
 	}
 
@@ -534,7 +533,7 @@ func createRun(opts *CreateOptions) error {
 			}
 		}
 
-		openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported)
+		openURL, err = generateCompareURL(*ctx, *state, projectsV1Support)
 		if err != nil {
 			return err
 		}
@@ -553,8 +552,7 @@ func createRun(opts *CreateOptions) error {
 				Repo:      ctx.PRRefs.BaseRepo(),
 				State:     state,
 			}
-			// TODO wm: revisit project support
-			err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported)
+			err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support)
 			if err != nil {
 				return err
 			}
@@ -583,12 +581,10 @@ func createRun(opts *CreateOptions) error {
 
 	if action == shared.SubmitDraftAction {
 		state.Draft = true
-		// TODO wm: revisit project support
 		return submitPR(*opts, *ctx, *state, projectsV1Support)
 	}
 
 	if action == shared.SubmitAction {
-		// TODO wm: revisit project support
 		return submitPR(*opts, *ctx, *state, projectsV1Support)
 	}
 
@@ -1237,7 +1233,6 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState, proj
 		ctx.PRRefs.BaseRepo(),
 		"compare/%s...%s?expand=1",
 		url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef()))
-	// TODO wm: revisit project support
 	url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, projectsV1Support)
 	if err != nil {
 		return "", err
diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go
index 3c8d73ad3..0428d13d6 100644
--- a/pkg/cmd/pr/edit/edit.go
+++ b/pkg/cmd/pr/edit/edit.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	shared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
@@ -19,6 +20,9 @@ import (
 type EditOptions struct {
 	HttpClient func() (*http.Client, error)
 	IO         *iostreams.IOStreams
+	// TODO projectsV1Deprecation
+	// Remove this detector since it is only used for test validation.
+	Detector fd.Detector
 
 	Finder          shared.PRFinder
 	Surveyor        Surveyor
@@ -193,6 +197,7 @@ func editRun(opts *EditOptions) error {
 	findOptions := shared.FindOptions{
 		Selector: opts.SelectorArg,
 		Fields:   []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"},
+		Detector: opts.Detector,
 	}
 	pr, repo, err := opts.Finder.Find(findOptions)
 	if err != nil {
diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go
index cdf6135b6..b90721412 100644
--- a/pkg/cmd/release/download/download.go
+++ b/pkg/cmd/release/download/download.go
@@ -165,10 +165,24 @@ func downloadRun(opts *DownloadOptions) error {
 	var toDownload []shared.ReleaseAsset
 	isArchive := false
 	if opts.ArchiveType != "" {
-		var archiveURL = release.ZipballURL
+		var archiveURL string
 		if opts.ArchiveType == "tar.gz" {
 			archiveURL = release.TarballURL
+		} else {
+			archiveURL = release.ZipballURL
+		}
+
+		if archiveURL == "" {
+			errMessage := fmt.Sprintf(
+				"release %q with tag %q, does not have a %q archive asset.",
+				release.Name, release.TagName, opts.ArchiveType,
+			)
+			if release.IsDraft {
+				errMessage += " Most likely, this is because it is a draft."
+			}
+			return errors.New(errMessage)
 		}
+
 		// create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL
 		toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL})
 		isArchive = true


Test patch:
diff --git a/api/export_pr_test.go b/api/export_pr_test.go
index 09a1dffe8..1f310693e 100644
--- a/api/export_pr_test.go
+++ b/api/export_pr_test.go
@@ -107,6 +107,70 @@ func TestIssue_ExportData(t *testing.T) {
 				}
 			`),
 		},
+		{
+			name:   "linked pull requests",
+			fields: []string{"closedByPullRequestsReferences"},
+			inputJSON: heredoc.Doc(`
+				{ "closedByPullRequestsReferences": { "nodes": [
+					{
+						"id": "I_123",
+						"number": 123,
+						"url": "https://github.com/cli/cli/pull/123",
+						"repository": {
+							"id": "R_123",
+							"name": "cli",
+							"owner": {
+								"id": "O_123",
+								"login": "cli"
+							}
+						}
+					},
+					{
+						"id": "I_456",
+						"number": 456,
+						"url": "https://github.com/cli/cli/pull/456",
+						"repository": {
+							"id": "R_456",
+							"name": "cli",
+							"owner": {
+								"id": "O_456",
+								"login": "cli"
+							}
+						}
+					}
+				] } }
+			`),
+			outputJSON: heredoc.Doc(`
+				{ "closedByPullRequestsReferences": [
+					{
+						"id": "I_123",
+						"number": 123,
+						"repository": {
+							"id": "R_123",
+							"name": "cli",
+							"owner": {
+								"id": "O_123",
+								"login": "cli"
+							}
+						},
+						"url": "https://github.com/cli/cli/pull/123"
+					},
+					{
+						"id": "I_456",
+						"number": 456,
+						"repository": {
+							"id": "R_456",
+							"name": "cli",
+							"owner": {
+								"id": "O_456",
+								"login": "cli"
+							}
+						},
+						"url": "https://github.com/cli/cli/pull/456"
+					}
+				] }
+			`),
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +184,14 @@ func TestIssue_ExportData(t *testing.T) {
 			enc := json.NewEncoder(&buf)
 			enc.SetIndent("", "\t")
 			require.NoError(t, enc.Encode(exported))
-			assert.Equal(t, tt.outputJSON, buf.String())
+
+			var gotData interface{}
+			dec = json.NewDecoder(&buf)
+			require.NoError(t, dec.Decode(&gotData))
+			var expectData interface{}
+			require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData))
+
+			assert.Equal(t, expectData, gotData)
 		})
 	}
 }
diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go
index ed4da8de8..9ba5d59b7 100644
--- a/internal/prompter/accessible_prompter_test.go
+++ b/internal/prompter/accessible_prompter_test.go
@@ -164,7 +164,12 @@ func TestAccessiblePrompter(t *testing.T) {
 
 		// Ensure the dummy password is not printed to the screen,
 		// asserting that echo mode is disabled.
-		_, err = console.ExpectString(" \r\n\r\n")
+		//
+		// Note that since console.ExpectString returns successful if the
+		// expected string matches any part of the stream, we have to use an
+		// anchored regexp (i.e., with ^ and $) to make sure the password/token
+		// is not printed at all.
+		_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
 		require.NoError(t, err)
 	})
 
@@ -230,7 +235,12 @@ func TestAccessiblePrompter(t *testing.T) {
 
 		// Ensure the dummy password is not printed to the screen,
 		// asserting that echo mode is disabled.
-		_, err = console.ExpectString(" \r\n\r\n")
+		//
+		// Note that since console.ExpectString returns successful if the
+		// expected string matches any part of the stream, we have to use an
+		// anchored regexp (i.e., with ^ and $) to make sure the password/token
+		// is not printed at all.
+		_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
 		require.NoError(t, err)
 	})
 
@@ -252,6 +262,10 @@ func TestAccessiblePrompter(t *testing.T) {
 			_, err = console.ExpectString("token is required")
 			require.NoError(t, err)
 
+			// Wait for the retry prompt
+			_, err = console.ExpectString("Paste your authentication token:")
+			require.NoError(t, err)
+
 			// Wait to ensure huh has time to set the echo mode
 			time.Sleep(beforePasswordSendTimeout)
 
@@ -266,7 +280,12 @@ func TestAccessiblePrompter(t *testing.T) {
 
 		// Ensure the dummy password is not printed to the screen,
 		// asserting that echo mode is disabled.
-		_, err = console.ExpectString(" \r\n\r\n")
+		//
+		// Note that since console.ExpectString returns successful if the
+		// expected string matches any part of the stream, we have to use an
+		// anchored regexp (i.e., with ^ and $) to make sure the password/token
+		// is not printed at all.
+		_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
 		require.NoError(t, err)
 	})
 
diff --git a/internal/prompter/test.go b/internal/prompter/test.go
index 04375ce76..dfa124fca 100644
--- a/internal/prompter/test.go
+++ b/internal/prompter/test.go
@@ -141,6 +141,18 @@ func IndexFor(options []string, answer string) (int, error) {
 	return -1, NoSuchAnswerErr(answer, options)
 }
 
+func IndexesFor(options []string, answers ...string) ([]int, error) {
+	indexes := make([]int, len(answers))
+	for i, answer := range answers {
+		index, err := IndexFor(options, answer)
+		if err != nil {
+			return nil, err
+		}
+		indexes[i] = index
+	}
+	return indexes, nil
+}
+
 func NoSuchPromptErr(prompt string) error {
 	return fmt.Errorf("no such prompt '%s'", prompt)
 }
diff --git a/pkg/cmd/attestation/api/attestation.go b/pkg/cmd/attestation/api/attestation.go
index fd6b484a7..daec12b50 100644
--- a/pkg/cmd/attestation/api/attestation.go
+++ b/pkg/cmd/attestation/api/attestation.go
@@ -1,7 +1,10 @@
 package api
 
 import (
+	"encoding/json"
 	"errors"
+	"fmt"
+
 	"github.com/sigstore/sigstore-go/pkg/bundle"
 )
 
@@ -20,3 +23,35 @@ type Attestation struct {
 type AttestationsResponse struct {
 	Attestations []*Attestation `json:"attestations"`
 }
+
+type IntotoStatement struct {
+	PredicateType string `json:"predicateType"`
+}
+
+func FilterAttestations(predicateType string, attestations []*Attestation) ([]*Attestation, error) {
+	filteredAttestations := []*Attestation{}
+
+	for _, each := range attestations {
+		dsseEnvelope := each.Bundle.GetDsseEnvelope()
+		if dsseEnvelope != nil {
+			if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" {
+				// Don't fail just because an entry isn't intoto
+				continue
+			}
+			var intotoStatement IntotoStatement
+			if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil {
+				// Don't fail just because a single entry can't be unmarshalled
+				continue
+			}
+			if intotoStatement.PredicateType == predicateType {
+				filteredAttestations = append(filteredAttestations, each)
+			}
+		}
+	}
+
+	if len(filteredAttestations) == 0 {
+		return nil, fmt.Errorf("no attestations found with predicate type: %s", predicateType)
+	}
+
+	return filteredAttestations, nil
+}
diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go
index 1e99a2a06..61d0bee52 100644
--- a/pkg/cmd/attestation/api/client.go
+++ b/pkg/cmd/attestation/api/client.go
@@ -27,6 +27,28 @@ const (
 // Allow injecting backoff interval in tests.
 var getAttestationRetryInterval = time.Millisecond * 200
 
+// FetchParams are the parameters for fetching attestations from the GitHub API
+type FetchParams struct {
+	Digest        string
+	Limit         int
+	Owner         string
+	PredicateType string
+	Repo          string
+}
+
+func (p *FetchParams) Validate() error {
+	if p.Digest == "" {
+		return fmt.Errorf("digest must be provided")
+	}
+	if p.Limit <= 0 || p.Limit > maxLimitForFlag {
+		return fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
+	}
+	if p.Repo == "" && p.Owner == "" {
+		return fmt.Errorf("owner or repo must be provided")
+	}
+	return nil
+}
+
 // githubApiClient makes REST calls to the GitHub API
 type githubApiClient interface {
 	REST(hostname, method, p string, body io.Reader, data interface{}) error
@@ -39,8 +61,7 @@ type httpClient interface {
 }
 
 type Client interface {
-	GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error)
-	GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error)
+	GetByDigest(params FetchParams) ([]*Attestation, error)
 	GetTrustDomain() (string, error)
 }
 
@@ -60,22 +81,11 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien
 	}
 }
 
-// GetByRepoAndDigest fetches the attestation by repo and digest
-func (c *LiveClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) {
-	c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
-	url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest)
-	return c.getByURL(url, limit)
-}
-
-// GetByOwnerAndDigest fetches attestation by owner and digest
-func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) {
-	c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
-	url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest)
-	return c.getByURL(url, limit)
-}
-
-func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) {
-	attestations, err := c.getAttestations(url, limit)
+// GetByDigest fetches the attestation by digest and either owner or repo
+// depending on which is provided
+func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) {
+	c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", params.Digest)
+	attestations, err := c.getAttestations(params)
 	if err != nil {
 		return nil, err
 	}
@@ -88,40 +98,52 @@ func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) {
 	return bundles, nil
 }
 
-// GetTrustDomain returns the current trust domain. If the default is used
-// the empty string is returned
-func (c *LiveClient) GetTrustDomain() (string, error) {
-	return c.getTrustDomain(MetaPath)
-}
+func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) {
+	if err := params.Validate(); err != nil {
+		return "", err
+	}
 
-func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, error) {
-	perPage := limit
-	if perPage <= 0 || perPage > maxLimitForFlag {
-		return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
+	var url string
+	if params.Repo != "" {
+		// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
+		// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
+		url = fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest)
+	} else {
+		url = fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest)
 	}
 
+	perPage := params.Limit
 	if perPage > maxLimitForFetch {
 		perPage = maxLimitForFetch
 	}
 
 	// ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96
 	url = fmt.Sprintf("%s?per_page=%d", url, perPage)
+	if params.PredicateType != "" {
+		url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType)
+	}
+	return url, nil
+}
+
+func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) {
+	url, err := c.buildRequestURL(params)
+	if err != nil {
+		return nil, err
+	}
 
 	var attestations []*Attestation
 	var resp AttestationsResponse
 	bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
 
 	// if no attestation or less than limit, then keep fetching
-	for url != "" && len(attestations) < limit {
+	for url != "" && len(attestations) < params.Limit {
 		err := backoff.Retry(func() error {
 			newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp)
-
 			if restErr != nil {
 				if shouldRetry(restErr) {
 					return restErr
-				} else {
-					return backoff.Permanent(restErr)
 				}
+				return backoff.Permanent(restErr)
 			}
 
 			url = newURL
@@ -140,8 +162,8 @@ func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, err
 		return nil, ErrNoAttestationsFound
 	}
 
-	if len(attestations) > limit {
-		return attestations[:limit], nil
+	if len(attestations) > params.Limit {
+		return attestations[:params.Limit], nil
 	}
 
 	return attestations, nil
@@ -241,6 +263,12 @@ func shouldRetry(err error) bool {
 	return false
 }
 
+// GetTrustDomain returns the current trust domain. If the default is used
+// the empty string is returned
+func (c *LiveClient) GetTrustDomain() (string, error) {
+	return c.getTrustDomain(MetaPath)
+}
+
 func (c *LiveClient) getTrustDomain(url string) (string, error) {
 	var resp MetaResponse
 
diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go
index 787408a4e..384c7c9c8 100644
--- a/pkg/cmd/attestation/api/client_test.go
+++ b/pkg/cmd/attestation/api/client_test.go
@@ -42,78 +42,75 @@ func NewClientWithMockGHClient(hasNextPage bool) Client {
 	}
 }
 
-func TestGetByDigest(t *testing.T) {
-	c := NewClientWithMockGHClient(false)
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
-	require.NoError(t, err)
-
-	require.Equal(t, 5, len(attestations))
-	bundle := (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
-	require.NoError(t, err)
-
-	require.Equal(t, 5, len(attestations))
-	bundle = (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
+var testFetchParamsWithOwner = FetchParams{
+	Digest:        testDigest,
+	Limit:         DefaultLimit,
+	Owner:         testOwner,
+	PredicateType: "https://slsa.dev/provenance/v1",
 }
-
-func TestGetByDigestGreaterThanLimit(t *testing.T) {
-	c := NewClientWithMockGHClient(false)
-
-	limit := 3
-	// The method should return five results when the limit is not set
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit)
-	require.NoError(t, err)
-
-	require.Equal(t, 3, len(attestations))
-	bundle := (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit)
-	require.NoError(t, err)
-
-	require.Equal(t, len(attestations), limit)
-	bundle = (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
+var testFetchParamsWithRepo = FetchParams{
+	Digest:        testDigest,
+	Limit:         DefaultLimit,
+	Repo:          testRepo,
+	PredicateType: "https://slsa.dev/provenance/v1",
 }
 
-func TestGetByDigestWithNextPage(t *testing.T) {
-	c := NewClientWithMockGHClient(true)
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
-	require.NoError(t, err)
-
-	require.Equal(t, len(attestations), 10)
-	bundle := (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
-	require.NoError(t, err)
-
-	require.Equal(t, len(attestations), 10)
-	bundle = (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
+type getByTestCase struct {
+	name                 string
+	params               FetchParams
+	limit                int
+	expectedAttestations int
+	hasNextPage          bool
 }
 
-func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) {
-	c := NewClientWithMockGHClient(true)
-
-	limit := 7
-	// The method should return five results when the limit is not set
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit)
-	require.NoError(t, err)
-
-	require.Equal(t, len(attestations), limit)
-	bundle := (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit)
-	require.NoError(t, err)
+var getByTestCases = []getByTestCase{
+	{
+		name:                 "get by digest with owner",
+		params:               testFetchParamsWithOwner,
+		expectedAttestations: 5,
+	},
+	{
+		name:                 "get by digest with repo",
+		params:               testFetchParamsWithRepo,
+		expectedAttestations: 5,
+	},
+	{
+		name:                 "get by digest with attestations greater than limit",
+		params:               testFetchParamsWithRepo,
+		limit:                3,
+		expectedAttestations: 3,
+	},
+	{
+		name:                 "get by digest with next page",
+		params:               testFetchParamsWithRepo,
+		expectedAttestations: 10,
+		hasNextPage:          true,
+	},
+	{
+		name:                 "greater than limit with next page",
+		params:               testFetchParamsWithRepo,
+		limit:                7,
+		expectedAttestations: 7,
+		hasNextPage:          true,
+	},
+}
 
-	require.Equal(t, len(attestations), limit)
-	bundle = (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
+func TestGetByDigest(t *testing.T) {
+	for _, tc := range getByTestCases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := NewClientWithMockGHClient(tc.hasNextPage)
+
+			if tc.limit > 0 {
+				tc.params.Limit = tc.limit
+			}
+			attestations, err := c.GetByDigest(tc.params)
+			require.NoError(t, err)
+
+			require.Equal(t, tc.expectedAttestations, len(attestations))
+			bundle := (attestations)[0].Bundle
+			require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
+		})
+	}
 }
 
 func TestGetByDigest_NoAttestationsFound(t *testing.T) {
@@ -130,12 +127,7 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) {
 		logger:     io.NewTestHandler(),
 	}
 
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
-	require.Error(t, err)
-	require.IsType(t, ErrNoAttestationsFound, err)
-	require.Nil(t, attestations)
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
+	attestations, err := c.GetByDigest(testFetchParamsWithRepo)
 	require.Error(t, err)
 	require.IsType(t, ErrNoAttestationsFound, err)
 	require.Nil(t, attestations)
@@ -153,11 +145,7 @@ func TestGetByDigest_Error(t *testing.T) {
 		logger: io.NewTestHandler(),
 	}
 
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
-	require.Error(t, err)
-	require.Nil(t, attestations)
-
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
+	attestations, err := c.GetByDigest(testFetchParamsWithRepo)
 	require.Error(t, err)
 	require.Nil(t, attestations)
 }
@@ -362,7 +350,8 @@ func TestGetAttestationsRetries(t *testing.T) {
 		logger:     io.NewTestHandler(),
 	}
 
-	attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
+	testFetchParamsWithRepo.Limit = 30
+	attestations, err := c.GetByDigest(testFetchParamsWithRepo)
 	require.NoError(t, err)
 
 	// assert the error path was executed; because this is a paged
@@ -373,17 +362,6 @@ func TestGetAttestationsRetries(t *testing.T) {
 	require.Equal(t, len(attestations), 10)
 	bundle := (attestations)[0].Bundle
 	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
-
-	// same test as above, but for GetByOwnerAndDigest:
-	attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
-	require.NoError(t, err)
-
-	// because we haven't reset the mock, we have added 2 more failed requests
-	fetcher.AssertNumberOfCalls(t, "FlakyOnRESTSuccessWithNextPage:error", 4)
-
-	require.Equal(t, len(attestations), 10)
-	bundle = (attestations)[0].Bundle
-	require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
 }
 
 // test total retries
@@ -401,7 +379,7 @@ func TestGetAttestationsMaxRetries(t *testing.T) {
 		logger: io.NewTestHandler(),
 	}
 
-	_, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
+	_, err := c.GetByDigest(testFetchParamsWithRepo)
 	require.Error(t, err)
 
 	fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4)
diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go
index b2fd334c0..b6062b39f 100644
--- a/pkg/cmd/attestation/api/mock_client.go
+++ b/pkg/cmd/attestation/api/mock_client.go
@@ -6,58 +6,49 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
 )
 
-type MockClient struct {
-	OnGetByRepoAndDigest  func(repo, digest string, limit int) ([]*Attestation, error)
-	OnGetByOwnerAndDigest func(owner, digest string, limit int) ([]*Attestation, error)
-	OnGetTrustDomain      func() (string, error)
+func makeTestAttestation() Attestation {
+	return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"}
 }
 
-func (m MockClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) {
-	return m.OnGetByRepoAndDigest(repo, digest, limit)
+type MockClient struct {
+	OnGetByDigest    func(params FetchParams) ([]*Attestation, error)
+	OnGetTrustDomain func() (string, error)
 }
 
-func (m MockClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) {
-	return m.OnGetByOwnerAndDigest(owner, digest, limit)
+func (m MockClient) GetByDigest(params FetchParams) ([]*Attestation, error) {
+	return m.OnGetByDigest(params)
 }
 
 func (m MockClient) GetTrustDomain() (string, error) {
 	return m.OnGetTrustDomain()
 }
 
-func makeTestAttestation() Attestation {
-	return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"}
-}
-
-func OnGetByRepoAndDigestSuccess(repo, digest string, limit int) ([]*Attestation, error) {
+func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) {
 	att1 := makeTestAttestation()
 	att2 := makeTestAttestation()
-	return []*Attestation{&att1, &att2}, nil
-}
+	attestations := []*Attestation{&att1, &att2}
+	if params.PredicateType != "" {
+		return FilterAttestations(params.PredicateType, attestations)
+	}
 
-func OnGetByRepoAndDigestFailure(repo, digest string, limit int) ([]*Attestation, error) {
-	return nil, fmt.Errorf("failed to fetch by repo and digest")
+	return attestations, nil
 }
 
-func OnGetByOwnerAndDigestSuccess(owner, digest string, limit int) ([]*Attestation, error) {
-	att1 := makeTestAttestation()
-	att2 := makeTestAttestation()
-	return []*Attestation{&att1, &att2}, nil
-}
-
-func OnGetByOwnerAndDigestFailure(owner, digest string, limit int) ([]*Attestation, error) {
-	return nil, fmt.Errorf("failed to fetch by owner and digest")
+func OnGetByDigestFailure(params FetchParams) ([]*Attestation, error) {
+	if params.Repo != "" {
+		return nil, fmt.Errorf("failed to fetch attestations from %s", params.Repo)
+	}
+	return nil, fmt.Errorf("failed to fetch attestations from %s", params.Owner)
 }
 
 func NewTestClient() *MockClient {
 	return &MockClient{
-		OnGetByRepoAndDigest:  OnGetByRepoAndDigestSuccess,
-		OnGetByOwnerAndDigest: OnGetByOwnerAndDigestSuccess,
+		OnGetByDigest: OnGetByDigestSuccess,
 	}
 }
 
 func NewFailTestClient() *MockClient {
 	return &MockClient{
-		OnGetByRepoAndDigest:  OnGetByRepoAndDigestFailure,
-		OnGetByOwnerAndDigest: OnGetByOwnerAndDigestFailure,
+		OnGetByDigest: OnGetByDigestFailure,
 	}
 }
diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go
index 6913c0787..8d1d1dc05 100644
--- a/pkg/cmd/attestation/download/download.go
+++ b/pkg/cmd/attestation/download/download.go
@@ -9,7 +9,6 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
 	"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
 	"github.com/cli/cli/v2/pkg/cmd/attestation/io"
-	"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 
@@ -127,13 +126,16 @@ func runDownload(opts *Options) error {
 
 	opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath)
 
-	params := verification.FetchRemoteAttestationsParams{
+	if opts.APIClient == nil {
+		return fmt.Errorf("no APIClient provided")
+	}
+	params := api.FetchParams{
 		Digest: artifact.DigestWithAlg(),
 		Limit:  opts.Limit,
 		Owner:  opts.Owner,
 		Repo:   opts.Repo,
 	}
-	attestations, err := verification.GetRemoteAttestations(opts.APIClient, params)
+	attestations, err := opts.APIClient.GetByDigest(params)
 	if err != nil {
 		if errors.Is(err, api.ErrNoAttestationsFound) {
 			fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath)
@@ -144,10 +146,9 @@ func runDownload(opts *Options) error {
 
 	// Apply predicate type filter to returned attestations
 	if opts.PredicateType != "" {
-		filteredAttestations := verification.FilterAttestations(opts.PredicateType, attestations)
-
-		if len(filteredAttestations) == 0 {
-			return fmt.Errorf("no attestations found with predicate type: %s", opts.PredicateType)
+		filteredAttestations, err := api.FilterAttestations(opts.PredicateType, attestations)
+		if err != nil {
+			return fmt.Errorf("failed to filter attestations: %v", err)
 		}
 
 		attestations = filteredAttestations
diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go
index ddcd08c92..11872daf9 100644
--- a/pkg/cmd/attestation/download/download_test.go
+++ b/pkg/cmd/attestation/download/download_test.go
@@ -275,7 +275,7 @@ func TestRunDownload(t *testing.T) {
 	t.Run("no attestations found", func(t *testing.T) {
 		opts := baseOpts
 		opts.APIClient = api.MockClient{
-			OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
+			OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) {
 				return nil, api.ErrNoAttestationsFound
 			},
 		}
@@ -291,7 +291,7 @@ func TestRunDownload(t *testing.T) {
 	t.Run("failed to fetch attestations", func(t *testing.T) {
 		opts := baseOpts
 		opts.APIClient = api.MockClient{
-			OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
+			OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) {
 				return nil, fmt.Errorf("failed to fetch attestations")
 			},
 		}
diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go
index db419ebac..10eb02ac4 100644
--- a/pkg/cmd/attestation/verification/attestation.go
+++ b/pkg/cmd/attestation/verification/attestation.go
@@ -20,13 +20,6 @@ const SLSAPredicateV1 = "https://slsa.dev/provenance/v1"
 var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not supported, must be json or jsonl")
 var ErrEmptyBundleFile = errors.New("provided bundle file is empty")
 
-type FetchRemoteAttestationsParams struct {
-	Digest string
-	Limit  int
-	Owner  string
-	Repo   string
-}
-
 // GetLocalAttestations returns a slice of attestations read from a local bundle file.
 func GetLocalAttestations(path string) ([]*api.Attestation, error) {
 	var attestations []*api.Attestation
@@ -89,28 +82,6 @@ func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) {
 	return attestations, nil
 }
 
-func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsParams) ([]*api.Attestation, error) {
-	if client == nil {
-		return nil, fmt.Errorf("api client must be provided")
-	}
-	// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
-	// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
-	if params.Repo != "" {
-		attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.Limit)
-		if err != nil {
-			return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err)
-		}
-		return attestations, nil
-	} else if params.Owner != "" {
-		attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.Limit)
-		if err != nil {
-			return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err)
-		}
-		return attestations, nil
-	}
-	return nil, fmt.Errorf("owner or repo must be provided")
-}
-
 func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) {
 	attestations, err := client.GetAttestations(artifact.NameRef(), artifact.DigestWithAlg())
 	if err != nil {
@@ -121,31 +92,3 @@ func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) (
 	}
 	return attestations, nil
 }
-
-type IntotoStatement struct {
-	PredicateType string `json:"predicateType"`
-}
-
-func FilterAttestations(predicateType string, attestations []*api.Attestation) []*api.Attestation {
-	filteredAttestations := []*api.Attestation{}
-
-	for _, each := range attestations {
-		dsseEnvelope := each.Bundle.GetDsseEnvelope()
-		if dsseEnvelope != nil {
-			if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" {
-				// Don't fail just because an entry isn't intoto
-				continue
-			}
-			var intotoStatement IntotoStatement
-			if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil {
-				// Don't fail just because a single entry can't be unmarshalled
-				continue
-			}
-			if intotoStatement.PredicateType == predicateType {
-				filteredAttestations = append(filteredAttestations, each)
-			}
-		}
-	}
-
-	return filteredAttestations
-}
diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go
index 8acff0c37..6826e2e40 100644
--- a/pkg/cmd/attestation/verification/attestation_test.go
+++ b/pkg/cmd/attestation/verification/attestation_test.go
@@ -157,10 +157,11 @@ func TestFilterAttestations(t *testing.T) {
 		},
 	}
 
-	filtered := FilterAttestations("https://slsa.dev/provenance/v1", attestations)
-
+	filtered, err := api.FilterAttestations("https://slsa.dev/provenance/v1", attestations)
 	require.Len(t, filtered, 1)
+	require.NoError(t, err)
 
-	filtered = FilterAttestations("NonExistentPredicate", attestations)
-	require.Len(t, filtered, 0)
+	filtered, err = api.FilterAttestations("NonExistentPredicate", attestations)
+	require.Nil(t, filtered)
+	require.Error(t, err)
 }
diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go
index bb96c9526..1b98fabf3 100644
--- a/pkg/cmd/attestation/verify/attestation.go
+++ b/pkg/cmd/attestation/verify/attestation.go
@@ -1,6 +1,7 @@
 package verify
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/cli/cli/v2/internal/text"
@@ -10,43 +11,63 @@ import (
 )
 
 func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) {
-	if o.BundlePath != "" {
-		attestations, err := verification.GetLocalAttestations(o.BundlePath)
+	// Fetch attestations from GitHub API within this if block since predicate type
+	// filter is done when the API is called
+	if o.FetchAttestationsFromGitHubAPI() {
+		if o.APIClient == nil {
+			errMsg := "✗ No APIClient provided"
+			return nil, errMsg, errors.New(errMsg)
+		}
+
+		params := api.FetchParams{
+			Digest:        a.DigestWithAlg(),
+			Limit:         o.Limit,
+			Owner:         o.Owner,
+			PredicateType: o.PredicateType,
+			Repo:          o.Repo,
+		}
+
+		attestations, err := o.APIClient.GetByDigest(params)
 		if err != nil {
-			msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL)
+			msg := "✗ Loading attestations from GitHub API failed"
 			return nil, msg, err
 		}
 		pluralAttestation := text.Pluralize(len(attestations), "attestation")
-		msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath)
+		msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation)
 		return attestations, msg, nil
 	}
 
-	if o.UseBundleFromRegistry {
-		attestations, err := verification.GetOCIAttestations(o.OCIClient, a)
+	// Fetch attestations from local bundle or OCI registry
+	// Predicate type filtering is done after the attestations are fetched
+	var attestations []*api.Attestation
+	var err error
+	var msg string
+	if o.BundlePath != "" {
+		attestations, err = verification.GetLocalAttestations(o.BundlePath)
 		if err != nil {
-			msg := "✗ Loading attestations from OCI registry failed"
-			return nil, msg, err
+			pluralAttestation := text.Pluralize(len(attestations), "attestation")
+			msg = fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath)
+		} else {
+			msg = fmt.Sprintf("Loaded %d attestations from %s", len(attestations), o.BundlePath)
+		}
+	} else if o.UseBundleFromRegistry {
+		attestations, err = verification.GetOCIAttestations(o.OCIClient, a)
+		if err != nil {
+			msg = "✗ Loading attestations from OCI registry failed"
+		} else {
+			pluralAttestation := text.Pluralize(len(attestations), "attestation")
+			msg = fmt.Sprintf("Loaded %s from OCI registry", pluralAttestation)
 		}
-		pluralAttestation := text.Pluralize(len(attestations), "attestation")
-		msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.ArtifactPath)
-		return attestations, msg, nil
 	}
-
-	params := verification.FetchRemoteAttestationsParams{
-		Digest: a.DigestWithAlg(),
-		Limit:  o.Limit,
-		Owner:  o.Owner,
-		Repo:   o.Repo,
+	if err != nil {
+		return nil, msg, err
 	}
 
-	attestations, err := verification.GetRemoteAttestations(o.APIClient, params)
+	filtered, err := api.FilterAttestations(o.PredicateType, attestations)
 	if err != nil {
-		msg := "✗ Loading attestations from GitHub API failed"
-		return nil, msg, err
+		return nil, err.Error(), err
 	}
-	pluralAttestation := text.Pluralize(len(attestations), "attestation")
-	msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation)
-	return attestations, msg, nil
+	return filtered, msg, nil
 }
 
 func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) {
diff --git a/pkg/cmd/attestation/verify/attestation_test.go b/pkg/cmd/attestation/verify/attestation_test.go
new file mode 100644
index 000000000..f015805ae
--- /dev/null
+++ b/pkg/cmd/attestation/verify/attestation_test.go
@@ -0,0 +1,71 @@
+package verify
+
+import (
+	"testing"
+
+	"github.com/cli/cli/v2/pkg/cmd/attestation/api"
+	"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
+	"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
+	"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGetAttestations_OCIRegistry_PredicateTypeFiltering(t *testing.T) {
+	artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
+	require.NoError(t, err)
+
+	o := &Options{
+		OCIClient:             oci.MockClient{},
+		PredicateType:         verification.SLSAPredicateV1,
+		Repo:                  "cli/cli",
+		UseBundleFromRegistry: true,
+	}
+	attestations, msg, err := getAttestations(o, *artifact)
+	require.NoError(t, err)
+	require.Contains(t, msg, "Loaded 2 attestations from OCI registry")
+	require.Len(t, attestations, 2)
+
+	o.PredicateType = "custom predicate type"
+	attestations, msg, err = getAttestations(o, *artifact)
+	require.Error(t, err)
+	require.Contains(t, msg, "no attestations found with predicate type")
+	require.Nil(t, attestations)
+}
+
+func TestGetAttestations_LocalBundle_PredicateTypeFiltering(t *testing.T) {
+	artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
+	require.NoError(t, err)
+
+	o := &Options{
+		BundlePath:    "../test/data/sigstore-js-2.1.0-bundle.json",
+		PredicateType: verification.SLSAPredicateV1,
+		Repo:          "sigstore/sigstore-js",
+	}
+	attestations, _, err := getAttestations(o, *artifact)
+	require.NoError(t, err)
+	require.Len(t, attestations, 1)
+
+	o.PredicateType = "custom predicate type"
+	attestations, _, err = getAttestations(o, *artifact)
+	require.Error(t, err)
+	require.Nil(t, attestations)
+}
+
+func TestGetAttestations_GhAPI_NoAttestationsFound(t *testing.T) {
+	artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
+	require.NoError(t, err)
+
+	o := &Options{
+		APIClient:     api.NewTestClient(),
+		PredicateType: verification.SLSAPredicateV1,
+		Repo:          "sigstore/sigstore-js",
+	}
+	attestations, _, err := getAttestations(o, *artifact)
+	require.NoError(t, err)
+	require.Len(t, attestations, 2)
+
+	o.PredicateType = "custom predicate type"
+	attestations, _, err = getAttestations(o, *artifact)
+	require.Error(t, err)
+	require.Nil(t, attestations)
+}
diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go
index 0fbbec55a..e47c4f4a8 100644
--- a/pkg/cmd/attestation/verify/options.go
+++ b/pkg/cmd/attestation/verify/options.go
@@ -53,6 +53,12 @@ func (opts *Options) Clean() {
 	}
 }
 
+// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API
+// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set
+func (opts *Options) FetchAttestationsFromGitHubAPI() bool {
+	return opts.BundlePath == "" && !opts.UseBundleFromRegistry
+}
+
 // AreFlagsValid checks that the provided flag combination is valid
 // and returns an error otherwise
 func (opts *Options) AreFlagsValid() error {
diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go
index b3bad519a..b8debc529 100644
--- a/pkg/cmd/attestation/verify/verify.go
+++ b/pkg/cmd/attestation/verify/verify.go
@@ -288,14 +288,6 @@ func runVerify(opts *Options) error {
 	// Print the message signifying success fetching attestations
 	opts.Logger.Println(logMsg)
 
-	// Apply predicate type filter to returned attestations
-	filteredAttestations := verification.FilterAttestations(ec.PredicateType, attestations)
-	if len(filteredAttestations) == 0 {
-		opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found with predicate type: %s\n"), opts.PredicateType)
-		return fmt.Errorf("no matching predicate found")
-	}
-	attestations = filteredAttestations
-
 	// print information about the policy that will be enforced against attestations
 	opts.Logger.Println("\nThe following policy criteria will be enforced:")
 	opts.Logger.Println(ec.BuildPolicyInformation())
diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go
index 092a009d8..2b821a435 100644
--- a/pkg/cmd/attestation/verify/verify_test.go
+++ b/pkg/cmd/attestation/verify/verify_test.go
@@ -510,7 +510,7 @@ func TestRunVerify(t *testing.T) {
 
 		err := runVerify(&customOpts)
 		require.Error(t, err)
-		require.ErrorContains(t, err, "no matching predicate found")
+		require.ErrorContains(t, err, "no attestations found with predicate type")
 	})
 
 	t.Run("with valid OCI artifact with UseBundleFromRegistry flag but no bundle return from registry", func(t *testing.T) {
diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go
index 391a288fb..71b0884a1 100644
--- a/pkg/cmd/issue/view/view_test.go
+++ b/pkg/cmd/issue/view/view_test.go
@@ -31,6 +31,7 @@ func TestJSONFields(t *testing.T) {
 		"body",
 		"closed",
 		"comments",
+		"closedByPullRequestsReferences",
 		"createdAt",
 		"closedAt",
 		"id",
diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go
index f3b99bc89..bd68f19d9 100644
--- a/pkg/cmd/pr/create/create_test.go
+++ b/pkg/cmd/pr/create/create_test.go
@@ -1852,11 +1852,13 @@ func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef {
 
 func Test_generateCompareURL(t *testing.T) {
 	tests := []struct {
-		name    string
-		ctx     CreateContext
-		state   shared.IssueMetadataState
-		want    string
-		wantErr bool
+		name              string
+		ctx               CreateContext
+		state             shared.IssueMetadataState
+		httpStubs         func(*testing.T, *httpmock.Registry)
+		projectsV1Support gh.ProjectsV1Support
+		want              string
+		wantErr           bool
 	}{
 		{
 			name: "basic",
@@ -1940,11 +1942,135 @@ func Test_generateCompareURL(t *testing.T) {
 			want:    "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&template=story.md",
 			wantErr: false,
 		},
+		// TODO projectsV1Deprecation
+		// Clean up these tests, but probably keep one for general project ID resolution.
+		{
+			name: "with projects, no v1 support",
+			ctx: CreateContext{
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
+						baseBranchName: "main",
+					},
+				},
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				// Ensure no v1 projects are requestd
+				// ( is required to avoid matching projectsV2
+				reg.Exclude(t, httpmock.GraphQL(`projects\(`))
+				reg.Register(
+					httpmock.GraphQL(`query RepositoryProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "repository": { "projectsV2": {
+								"nodes": [
+									{ "title": "ProjectTitle", "id": "PROJECTV2ID", "resourcePath": "/OWNER/REPO/projects/3" }
+								],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+				reg.Register(
+					httpmock.GraphQL(`query OrganizationProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "organization": { "projectsV2": {
+								"nodes": [],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+				reg.Register(
+					httpmock.GraphQL(`query UserProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "viewer": { "projectsV2": {
+								"nodes": [],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+			},
+			state: shared.IssueMetadataState{
+				ProjectTitles: []string{"ProjectTitle"},
+			},
+			projectsV1Support: gh.ProjectsV1Unsupported,
+			want:              "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F3",
+			wantErr:           false,
+		},
+		{
+			name: "with projects, v1 support",
+			ctx: CreateContext{
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
+						baseBranchName: "main",
+					},
+				},
+			},
+			state: shared.IssueMetadataState{
+				ProjectTitles: []string{"ProjectV1Title"},
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				// v1 project query responses
+				reg.Register(
+					httpmock.GraphQL(`query RepositoryProjectList\b`),
+					httpmock.StringResponse(`
+							{ "data": { "repository": { "projects": {
+								"nodes": [
+									{ "name": "ProjectV1Title", "id": "PROJECTV1ID", "resourcePath": "/OWNER/REPO/projects/1" }
+								],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+				reg.Register(
+					httpmock.GraphQL(`query OrganizationProjectList\b`),
+					httpmock.StringResponse(`
+										{ "data": { "organization": { "projects": {
+											"nodes": [],
+											"pageInfo": { "hasNextPage": false }
+										} } } }
+										`))
+				// v2 project query responses
+				reg.Register(
+					httpmock.GraphQL(`query RepositoryProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "repository": { "projectsV2": {
+								"nodes": [],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+				reg.Register(
+					httpmock.GraphQL(`query OrganizationProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "organization": { "projectsV2": {
+								"nodes": [],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+				reg.Register(
+					httpmock.GraphQL(`query UserProjectV2List\b`),
+					httpmock.StringResponse(`
+							{ "data": { "viewer": { "projectsV2": {
+								"nodes": [],
+								"pageInfo": { "hasNextPage": false }
+							} } } }
+							`))
+			},
+			projectsV1Support: gh.ProjectsV1Supported,
+			want:              "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F1",
+			wantErr:           false,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			// TODO wm: projects v1 support?
-			got, err := generateCompareURL(tt.ctx, tt.state, gh.ProjectsV1Supported)
+			// If http stubs are provided, register them and inject the registry into a client
+			// that is provided to generateCompareURL in the ctx.
+			if tt.httpStubs != nil {
+				reg := &httpmock.Registry{}
+				defer reg.Verify(t)
+
+				tt.httpStubs(t, reg)
+				tt.ctx.Client = api.NewClientFromHTTP(&http.Client{Transport: reg})
+			}
+
+			got, err := generateCompareURL(tt.ctx, tt.state, tt.projectsV1Support)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr)
 				return
@@ -2011,8 +2137,6 @@ func mockRetrieveProjects(_ *testing.T, reg *httpmock.Registry) {
 				`))
 }
 
-// TODO interactive metadata tests once: 1) we have test utils for Prompter and 2) metadata questions use Prompter
-
 // TODO projectsV1Deprecation
 // Remove this test.
 func TestProjectsV1Deprecation(t *testing.T) {
@@ -2127,4 +2251,324 @@ func TestProjectsV1Deprecation(t *testing.T) {
 			reg.Verify(t)
 		})
 	})
+
+	t.Run("interactive submission", func(t *testing.T) {
+		t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
+			cs, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+			cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "")
+			cs.Register(`git rev-parse --show-toplevel`, 0, "")
+
+			// When the command is run
+			reg := &httpmock.Registry{}
+			reg.StubRepoResponse("OWNER", "REPO")
+
+			reg.Register(
+				httpmock.GraphQL(`query PullRequestTemplates\b`),
+				httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`),
+			)
+
+			reg.Register(
+				// ( is required to avoid matching projectsV2
+				httpmock.GraphQL(`projects\(`),
+				// Simulate a GraphQL error to early exit the test.
+				httpmock.StatusStringResponse(500, ""),
+			)
+
+			// Register a handler to check for projects V2 just to avoid the registry panicking, even
+			// though we return a 500 error. This is because the project lookup is done in parallel
+			// so the previous error doesn't early exit.
+			reg.Register(
+				httpmock.GraphQL(`projectsV2`),
+				// Simulate a GraphQL error to early exit the test.
+				httpmock.StatusStringResponse(500, ""),
+			)
+
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdinTTY(true)
+			ios.SetStdoutTTY(true)
+			ios.SetStderrTTY(true)
+
+			pm := &prompter.PrompterMock{}
+			pm.InputFunc = func(p, _ string) (string, error) {
+				if p == "Title (required)" {
+					return "Test Title", nil
+				} else {
+					return "", prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) {
+				if p == "Body" {
+					return "Test Body", nil
+				} else {
+					return "", prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
+				switch p {
+				case "Choose a template":
+					return 0, nil
+				case "What's next?":
+					return prompter.IndexFor(opts, "Add metadata")
+				default:
+					return -1, prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) {
+				return prompter.IndexesFor(opts, "Projects")
+			}
+
+			opts := CreateOptions{
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				Config: func() (gh.Config, error) {
+					return config.NewBlankConfig(), nil
+				},
+				Browser:  &browser.Stub{},
+				IO:       ios,
+				Prompter: pm,
+				GitClient: &git.Client{
+					GhPath:  "some/path/gh",
+					GitPath: "some/path/git",
+				},
+				Finder:   shared.NewMockFinder("feature", nil, nil),
+				Detector: &fd.EnabledDetectorMock{},
+				Remotes: func() (context.Remotes, error) {
+					return context.Remotes{
+						{
+							Remote: &git.Remote{
+								Name: "origin",
+							},
+							Repo: ghrepo.New("OWNER", "REPO"),
+						},
+					}, nil
+				},
+				Branch: func() (string, error) {
+					return "feature", nil
+				},
+
+				HeadBranch: "feature",
+			}
+
+			// Ignore the error because we have no way to really stub it without
+			// fully stubbing a GQL error structure in the request body.
+			_ = createRun(&opts)
+
+			// Verify that our request contained projects
+			reg.Verify(t)
+		})
+
+		t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
+			cs, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+			cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "")
+			cs.Register(`git rev-parse --show-toplevel`, 0, "")
+
+			// When the command is run
+			reg := &httpmock.Registry{}
+			reg.StubRepoResponse("OWNER", "REPO")
+
+			reg.Register(
+				httpmock.GraphQL(`query PullRequestTemplates\b`),
+				httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`),
+			)
+
+			// ( is required to avoid matching projectsV2
+			reg.Exclude(t, httpmock.GraphQL(`projects\(`))
+
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdinTTY(true)
+			ios.SetStdoutTTY(true)
+			ios.SetStderrTTY(true)
+
+			pm := &prompter.PrompterMock{}
+			pm.InputFunc = func(p, _ string) (string, error) {
+				if p == "Title (required)" {
+					return "Test Title", nil
+				} else {
+					return "", prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) {
+				if p == "Body" {
+					return "Test Body", nil
+				} else {
+					return "", prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
+				switch p {
+				case "Choose a template":
+					return 0, nil
+				case "What's next?":
+					return prompter.IndexFor(opts, "Add metadata")
+				default:
+					return -1, prompter.NoSuchPromptErr(p)
+				}
+			}
+			pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) {
+				return prompter.IndexesFor(opts, "Projects")
+			}
+
+			opts := CreateOptions{
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				Config: func() (gh.Config, error) {
+					return config.NewBlankConfig(), nil
+				},
+				Browser:  &browser.Stub{},
+				IO:       ios,
+				Prompter: pm,
+				GitClient: &git.Client{
+					GhPath:  "some/path/gh",
+					GitPath: "some/path/git",
+				},
+				Finder:   shared.NewMockFinder("feature", nil, nil),
+				Detector: &fd.DisabledDetectorMock{},
+				Remotes: func() (context.Remotes, error) {
+					return context.Remotes{
+						{
+							Remote: &git.Remote{
+								Name: "origin",
+							},
+							Repo: ghrepo.New("OWNER", "REPO"),
+						},
+					}, nil
+				},
+				Branch: func() (string, error) {
+					return "feature", nil
+				},
+
+				HeadBranch: "feature",
+			}
+
+			// Ignore the error because we have no way to really stub it without
+			// fully stubbing a GQL error structure in the request body.
+			_ = createRun(&opts)
+
+			// Verify that our request did not contain projectCards
+			reg.Verify(t)
+		})
+	})
+
+	t.Run("web mode", func(t *testing.T) {
+		t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+
+			reg := &httpmock.Registry{}
+			reg.StubRepoInfoResponse("OWNER", "REPO", "main")
+			reg.Register(
+				// ( is required to avoid matching projectsV2
+				httpmock.GraphQL(`projects\(`),
+				// Simulate a GraphQL error to early exit the test.
+				httpmock.StatusStringResponse(500, ""),
+			)
+
+			cs, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+
+			// Ignore the error because we have no way to really stub it without
+			// fully stubbing a GQL error structure in the request body.
+			_ = createRun(&CreateOptions{
+				Detector: &fd.EnabledDetectorMock{},
+				IO:       ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				GitClient: &git.Client{
+					GhPath:  "some/path/gh",
+					GitPath: "some/path/git",
+				},
+				Remotes: func() (context.Remotes, error) {
+					return context.Remotes{
+						{
+							Remote: &git.Remote{
+								Name:     "upstream",
+								Resolved: "base",
+							},
+							Repo: ghrepo.New("OWNER", "REPO"),
+						},
+					}, nil
+				},
+				Finder: shared.NewMockFinder("feature", nil, nil),
+
+				WebMode: true,
+
+				HeadBranch: "feature",
+
+				TitleProvided: true,
+				BodyProvided:  true,
+				Title:         "Test Title",
+				Body:          "Test Body",
+
+				// Required to force a lookup of projects
+				Projects: []string{"Project"},
+			})
+
+			// Verify that our request contained projects
+			reg.Verify(t)
+		})
+
+		t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+
+			reg := &httpmock.Registry{}
+			reg.StubRepoInfoResponse("OWNER", "REPO", "main")
+			// ( is required to avoid matching projectsV2
+			reg.Exclude(t, httpmock.GraphQL(`projects\(`))
+
+			cs, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+
+			// Ignore the error because we're not really interested in it.
+			_ = createRun(&CreateOptions{
+				Detector: &fd.DisabledDetectorMock{},
+				IO:       ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				GitClient: &git.Client{
+					GhPath:  "some/path/gh",
+					GitPath: "some/path/git",
+				},
+				Remotes: func() (context.Remotes, error) {
+					return context.Remotes{
+						{
+							Remote: &git.Remote{
+								Name:     "upstream",
+								Resolved: "base",
+							},
+							Repo: ghrepo.New("OWNER", "REPO"),
+						},
+					}, nil
+				},
+				Finder: shared.NewMockFinder("feature", nil, nil),
+
+				WebMode: true,
+
+				HeadBranch: "feature",
+
+				TitleProvided: true,
+				BodyProvided:  true,
+				Title:         "Test Title",
+				Body:          "Test Body",
+
+				// Required to force a lookup of projects
+				Projects: []string{"Project"},
+			})
+
+			// Verify that our request did not contain projectCards
+			reg.Verify(t)
+		})
+	})
 }
diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go
index 3c4882961..b09ee104e 100644
--- a/pkg/cmd/pr/edit/edit_test.go
+++ b/pkg/cmd/pr/edit/edit_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/cli/cli/v2/api"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	shared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -696,3 +697,73 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error {
 func (t testEditorRetriever) Retrieve() (string, error) {
 	return "vim", nil
 }
+
+// TODO projectsV1Deprecation
+// Remove this test.
+func TestProjectsV1Deprecation(t *testing.T) {
+	t.Run("when projects v1 is supported, is included in query", func(t *testing.T) {
+		ios, _, _, _ := iostreams.Test()
+
+		reg := &httpmock.Registry{}
+		reg.Register(
+			httpmock.GraphQL(`projectCards`),
+			// Simulate a GraphQL error to early exit the test.
+			httpmock.StatusStringResponse(500, ""),
+		)
+
+		f := &cmdutil.Factory{
+			IOStreams: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+		}
+
+		// Ignore the error because we have no way to really stub it without
+		// fully stubbing a GQL error structure in the request body.
+		_ = editRun(&EditOptions{
+			IO: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+			Detector: &fd.EnabledDetectorMock{},
+
+			Finder: shared.NewFinder(f),
+
+			SelectorArg: "https://github.com/cli/cli/pull/123",
+		})
+
+		// Verify that our request contained projectCards
+		reg.Verify(t)
+	})
+
+	t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) {
+		ios, _, _, _ := iostreams.Test()
+
+		reg := &httpmock.Registry{}
+		reg.Exclude(t, httpmock.GraphQL(`projectCards`))
+
+		f := &cmdutil.Factory{
+			IOStreams: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+		}
+
+		// Ignore the error because we have no way to really stub it without
+		// fully stubbing a GQL error structure in the request body.
+		_ = editRun(&EditOptions{
+			IO: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+			Detector: &fd.DisabledDetectorMock{},
+
+			Finder: shared.NewFinder(f),
+
+			SelectorArg: "https://github.com/cli/cli/pull/123",
+		})
+
+		// Verify that our request did not contain projectCards
+		reg.Verify(t)
+	})
+}
diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go
index 78709dd57..9337c9b65 100644
--- a/pkg/cmd/release/download/download_test.go
+++ b/pkg/cmd/release/download/download_test.go
@@ -183,6 +183,7 @@ func Test_downloadRun(t *testing.T) {
 		name       string
 		isTTY      bool
 		opts       DownloadOptions
+		httpStubs  func(*httpmock.Registry)
 		wantErr    string
 		wantStdout string
 		wantStderr string
@@ -196,6 +197,24 @@ func Test_downloadRun(t *testing.T) {
 				Destination: ".",
 				Concurrency: 2,
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
+				reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`))
+				reg.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`))
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -213,6 +232,23 @@ func Test_downloadRun(t *testing.T) {
 				Destination:  "tmp/assets",
 				Concurrency:  2,
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
+				reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`))
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -229,6 +265,20 @@ func Test_downloadRun(t *testing.T) {
 				Destination:  ".",
 				Concurrency:  2,
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantErr:    "no assets match the file pattern",
@@ -242,6 +292,30 @@ func Test_downloadRun(t *testing.T) {
 				Destination: "tmp/packages",
 				Concurrency: 2,
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(
+					httpmock.REST(
+						"GET",
+						"repos/OWNER/REPO/zipball/v1.2.3",
+					),
+					httpmock.WithHeader(
+						httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip",
+					),
+				)
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -257,6 +331,30 @@ func Test_downloadRun(t *testing.T) {
 				Destination: "tmp/packages",
 				Concurrency: 2,
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(
+					httpmock.REST(
+						"GET",
+						"repos/OWNER/REPO/tarball/v1.2.3",
+					),
+					httpmock.WithHeader(
+						httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz",
+					),
+				)
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -273,6 +371,30 @@ func Test_downloadRun(t *testing.T) {
 				Concurrency: 2,
 				ArchiveType: "tar.gz",
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(
+					httpmock.REST(
+						"GET",
+						"repos/OWNER/REPO/tarball/v1.2.3",
+					),
+					httpmock.WithHeader(
+						httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz",
+					),
+				)
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -289,6 +411,22 @@ func Test_downloadRun(t *testing.T) {
 				Concurrency:  2,
 				FilePatterns: []string{"*windows-32bit.zip"},
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
+			},
 			wantStdout: ``,
 			wantStderr: ``,
 			wantFiles: []string{
@@ -305,9 +443,85 @@ func Test_downloadRun(t *testing.T) {
 				Concurrency:  2,
 				FilePatterns: []string{"*windows-32bit.zip"},
 			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						"url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						"url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						"url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
+					"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
+				}`)
+
+				reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
+			},
 			wantStdout: `1234`,
 			wantStderr: ``,
 		},
+		{
+			name:  "draft release with null tarball_url and zipball_url",
+			isTTY: true,
+			opts: DownloadOptions{
+				TagName:     "v1.2.3",
+				ArchiveType: "tar.gz",
+				Destination: "tmp/packages",
+				Concurrency: 2,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"tag_name": "v1.2.3",
+					"name": "patch-36",
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						  "url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						  "url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						  "url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": null,
+					"zipball_url": null,
+					"draft": true
+				}`)
+			},
+			wantStdout: ``,
+			wantStderr: ``,
+			wantErr:    "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset. Most likely, this is because it is a draft.",
+		},
+		{
+			name:  "non-draft release with null tarball_url and zipball_url",
+			isTTY: true,
+			opts: DownloadOptions{
+				TagName:     "v1.2.3",
+				ArchiveType: "tar.gz",
+				Destination: "tmp/packages",
+				Concurrency: 2,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{
+					"tag_name": "v1.2.3",
+					"name": "patch-36",
+					"assets": [
+						{ "name": "windows-32bit.zip", "size": 12,
+						  "url": "https://api.github.com/assets/1234" },
+						{ "name": "windows-64bit.zip", "size": 34,
+						  "url": "https://api.github.com/assets/3456" },
+						{ "name": "linux.tgz", "size": 56,
+						  "url": "https://api.github.com/assets/5678" }
+					],
+					"tarball_url": null,
+					"zipball_url": null,
+					"draft": false
+				}`)
+			},
+			wantStdout: ``,
+			wantStderr: ``,
+			wantErr:    "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset.",
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -324,41 +538,11 @@ func Test_downloadRun(t *testing.T) {
 			ios.SetStderrTTY(tt.isTTY)
 
 			fakeHTTP := &httpmock.Registry{}
-			shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", tt.opts.TagName, `{
-				"assets": [
-					{ "name": "windows-32bit.zip", "size": 12,
-					  "url": "https://api.github.com/assets/1234" },
-					{ "name": "windows-64bit.zip", "size": 34,
-					  "url": "https://api.github.com/assets/3456" },
-					{ "name": "linux.tgz", "size": 56,
-					  "url": "https://api.github.com/assets/5678" }
-				],
-				"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
-				"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
-			}`)
-			fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
-			fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`))
-			fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`))
-
-			fakeHTTP.Register(
-				httpmock.REST(
-					"GET",
-					"repos/OWNER/REPO/tarball/v1.2.3",
-				),
-				httpmock.WithHeader(
-					httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz",
-				),
-			)
-
-			fakeHTTP.Register(
-				httpmock.REST(
-					"GET",
-					"repos/OWNER/REPO/zipball/v1.2.3",
-				),
-				httpmock.WithHeader(
-					httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip",
-				),
-			)
+			defer fakeHTTP.Verify(t)
+
+			if tt.httpStubs != nil {
+				tt.httpStubs(fakeHTTP)
+			}
 
 			tt.opts.IO = ios
 			tt.opts.HttpClient = func() (*http.Client, error) {
diff --git a/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh b/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh
index 647a13a4c..cea3c7228 100644
--- a/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh
+++ b/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh
@@ -14,3 +14,9 @@ if ! $ghBuildPath attestation verify "$ghCLIArtifact" --digest-alg=sha256 --owne
     echo "Failed to verify"
     exit 1
 fi
+
+# Try to verify when specifying a predicate type that does not match the attestation
+if $ghBuildPath attestation verify "$ghCLIArtifact" --digest-alg=sha256 --owner=cli --predicate-type=my-custom-predicate-type; then
+    echo "Verification should have failed"
+    exit 1
+fi