Repository: cli/cli

Issue:
Add field to retrieve linked issues (`closingIssuesReferences`) in `gh pr view <number>` 
### Describe the feature or problem you’d like to solve

Access to issues linked to Pull Requests in the Development panel. This field is available in GraphQL but not yet exposed in gh CLI. The GraphQL query to retrieve this field was shared in https://github.com/cli/cli/discussions/7097 

```bash
gh api graphql -F owner='{owner}' -F repo='{repo}' -F pr=PRNUMBER -f query='
query ($owner: String!, $repo: String!, $pr: Int!) {
	repository(owner: $owner, name: $repo) {
		pullRequest(number: $pr) {
			closingIssuesReferences(first: 100) {
				nodes {
					number
				}
			}
		}
	}
}'  --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number'
```

### Proposed solution

Exposing `closingIssuesReferences` as a field. E.g. `gh view pr <number> --json closingIssuesReferences`.




Hints from issue comments:
@jtmcg: Hey @vvolkgang, seems reasonable. Thanks for the suggestion!

## Acceptance Criteria

**Given** an PR references an issue to close
**when** I use `gh pr view <number> --json closingIssueReferences[, other fields...]`
**then** I get a JSON object containing an array of the closingIssuesReferences with issue numbers

```
$ gh pr view 101 --json title,number,closingIssueReferences
{
    "title": "PR title",
    "number": 101,
    "closingIssueReferences": [
        98,
        99
    ]
}
```@EdouardF: Are we looking to have the opposite: `gh issue view` that shows which PRs would close the issue?
Or do we have that already and I'm blind?@jtmcg: > Are we looking to have the opposite: `gh issue view` that shows which PRs would close the issue? Or do we have that already and I'm blind?

HI @EdouardF, I don't think this field exists for `gh issue view`, so I suppose we could add that enhancement as well. Will you make another issue for it and link this one?@babakks: @williammartin @iamazeem As of our discussion in the PR #10544, here's the updated acceptance criteria:

## Acceptance Criteria

**Given** an PR references an issue to close
**when** I use `gh pr view <number> --json closingIssueReferences[, other fields...]`
**then** I get a JSON object containing `closingIssuesReferences` as an array of referenced issues with sufficient details to locate the issue:

```
$ gh pr view 101 --json title,number,closingIssueReferences
{
    "title": "PR title",
    "number": 101,
    "closingIssuesReferences": [
        {
            "number": 999,
            "id": "issue-id",
            "url": "https://github.com/...",
            "repository": {
                "id": "repo-id",
                "name": "repo-name",
                "owner": {
                    "id": "owner-id",
                    "login": "owner-login"
                }
            }
        }
    ]
}
```

Note that the nested structure (for `repository` and `owner` fields) is mainly to keep the schema consistent with `gh repo view --json` output.

Solution patch:
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4e2032a87..a1ed27d99 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -62,9 +62,9 @@ To propose a design:
 - Include a link to the issue that the design is for.
 - Describe the design you are proposing to resolve the issue, leveraging the [CLI Design System][].
 - Mock up the design you are proposing using our [Google Docs Template][] or code blocks.
-  - Mock ups should cleary illustrate the command(s) being run and the expected output(s).
+  - Mock ups should clearly illustrate the command(s) being run and the expected output(s).
 
-### (core team only) Revewing a design
+### (core team only) Reviewing a design
 
 A member of the core team will [triage](../docs/triage.md) the design proposal. Once a member of the core team has reviewed the design, they may add the [`help wanted`][hw] label to the issue, so a PR can be opened to provide the implementation.
 
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index ced49a9ac..a7b03f40d 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -50,7 +50,7 @@ jobs:
         with:
           go-version-file: 'go.mod'
       - name: Install GoReleaser
-        uses: goreleaser/goreleaser-action@v6
+        uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552
         with:
           version: "~1.17.1"
           install-only: true
@@ -103,7 +103,7 @@ jobs:
           security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain"
           rm "$RUNNER_TEMP/cert.p12"
       - name: Install GoReleaser
-        uses: goreleaser/goreleaser-action@v6
+        uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552
         with:
           version: "~1.17.1"
           install-only: true
@@ -157,7 +157,7 @@ jobs:
         with:
           go-version-file: 'go.mod'
       - name: Install GoReleaser
-        uses: goreleaser/goreleaser-action@v6
+        uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552
         with:
           version: "~1.17.1"
           install-only: true
@@ -196,7 +196,7 @@ jobs:
         run: script/release --local "$TAG_NAME" --platform windows
       - name: Set up MSBuild
         id: setupmsbuild
-        uses: microsoft/setup-msbuild@v2.0.0
+        uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce
       - name: Build MSI
         shell: bash
         env:
@@ -301,7 +301,7 @@ jobs:
           base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes
           echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
           gpg-connect-agent RELOADAGENT /bye
-          /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" <<<"$GPG_PASSPHRASE"
+          base64 -d <<<"$GPG_PASSPHRASE" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP"
       - name: Sign RPMs
         if: inputs.environment == 'production'
         run: |
@@ -384,7 +384,7 @@ jobs:
             git diff --name-status @{upstream}..
           fi
       - name: Bump homebrew-core formula
-        uses: mislav/bump-homebrew-formula-action@v3
+        uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b
         if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
         with:
           formula-name: gh
diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml
index 2bc395a1e..228f1a345 100644
--- a/.github/workflows/homebrew-bump.yml
+++ b/.github/workflows/homebrew-bump.yml
@@ -17,7 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Bump homebrew-core formula
-        uses: mislav/bump-homebrew-formula-action@v3
+        uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b
         if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
         with:
           formula-name: gh
diff --git a/.github/workflows/issueauto.yml b/.github/workflows/issueauto.yml
index 40c4b36e7..cfdcff764 100644
--- a/.github/workflows/issueauto.yml
+++ b/.github/workflows/issueauto.yml
@@ -10,6 +10,7 @@ permissions:
 jobs:
   issue-auto:
     runs-on: ubuntu-latest
+    environment: cli-automation
     steps:
       - name: label incoming issue
         env:
diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml
index b7ed90183..40dfee846 100644
--- a/.github/workflows/prauto.yml
+++ b/.github/workflows/prauto.yml
@@ -11,6 +11,7 @@ permissions:
 jobs:
   pr-auto:
     runs-on: ubuntu-latest
+    environment: cli-automation
     steps:
       - name: lint pr
         env:
diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml
index 849beebad..3ed27a5f2 100644
--- a/.github/workflows/triage.yml
+++ b/.github/workflows/triage.yml
@@ -11,6 +11,7 @@ env:
   TARGET_REPO: github/cli
 jobs:
   issue:
+    environment: cli-discuss-automation
     runs-on: ubuntu-latest
     if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
     steps:
@@ -42,6 +43,7 @@ jobs:
 
   pull_request:
     runs-on: ubuntu-latest
+    environment: cli-discuss-automation
     if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
     steps:
       - name: Create issue based on source pull request
diff --git a/.goreleaser.yml b/.goreleaser.yml
index a7b293d6e..6ef1ecc8b 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -39,8 +39,7 @@ builds:
     goarch: [386, amd64, arm64]
     hooks:
       post:
-        - cmd: >-
-            {{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ else }}./script/sign{{ end }} '{{ .Path }}'
+        - cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
           output: true
     binary: bin/gh
     main: ./cmd/gh
diff --git a/README.md b/README.md
index fbd99a5a8..cefe1abb0 100644
--- a/README.md
+++ b/README.md
@@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a
   $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \
         --new-bundle-format \
         --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
-        --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \
+        --certificate-identity="https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" \
         gh_2.62.0_macOS_arm64.zip
   Verified OK
   ```
diff --git a/api/export_pr.go b/api/export_pr.go
index bb3310811..7ae1a4ff4 100644
--- a/api/export_pr.go
+++ b/api/export_pr.go
@@ -139,6 +139,25 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
 				}
 			}
 			data[f] = &requests
+		case "closingIssuesReferences":
+			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,
+					"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()
diff --git a/api/queries_pr.go b/api/queries_pr.go
index aa493b5e9..5b941bb42 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -93,6 +93,8 @@ type PullRequest struct {
 	Reviews        PullRequestReviews
 	LatestReviews  PullRequestReviews
 	ReviewRequests ReviewRequests
+
+	ClosingIssuesReferences ClosingIssuesReferences
 }
 
 type StatusCheckRollupNode struct {
@@ -107,6 +109,26 @@ type CommitStatusCheckRollup struct {
 	Contexts CheckContexts
 }
 
+type ClosingIssuesReferences 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
+	}
+}
+
 // https://docs.github.com/en/graphql/reference/enums#checkrunstate
 type CheckRunState string
 
diff --git a/api/queries_repo.go b/api/queries_repo.go
index 53e6d879a..27e21eb32 100644
--- a/api/queries_repo.go
+++ b/api/queries_repo.go
@@ -12,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghinstance"
 	"golang.org/x/sync/errgroup"
 
@@ -782,35 +783,54 @@ func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bo
 	return "", false
 }
 
-func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []string) ([]string, error) {
-	var paths []string
-	for _, projectName := range names {
-		found := false
-		for _, p := range projects {
-			if strings.EqualFold(projectName, p.Name) {
-				// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER
-				// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER
-				var path string
-				pathParts := strings.Split(p.ResourcePath, "/")
-				if pathParts[1] == "orgs" || pathParts[1] == "users" {
-					path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
-				} else {
-					path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
+func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) {
+	paths := make([]string, 0, len(projectNames))
+	matchedPaths := map[string]struct{}{}
+
+	// TODO: ProjectsV1Cleanup
+	// At this point, we only know the names that the user has provided, so we can't push this conditional up the stack.
+	// First we'll try to match against v1 projects, if supported
+	if projectsV1Support == gh.ProjectsV1Supported {
+		v1Projects, err := v1Projects(client, repo)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, projectName := range projectNames {
+			for _, p := range v1Projects {
+				if strings.EqualFold(projectName, p.Name) {
+					pathParts := strings.Split(p.ResourcePath, "/")
+					var path string
+					if pathParts[1] == "orgs" || pathParts[1] == "users" {
+						path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
+					} else {
+						path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
+					}
+					paths = append(paths, path)
+					matchedPaths[projectName] = struct{}{}
+					break
 				}
-				paths = append(paths, path)
-				found = true
-				break
 			}
 		}
-		if found {
+	}
+
+	// Then we'll try to match against v2 projects
+	v2Projects, err := v2Projects(client, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, projectName := range projectNames {
+		// If we already found a v1 project with this name, skip it
+		if _, ok := matchedPaths[projectName]; ok {
 			continue
 		}
-		for _, p := range projectsV2 {
+
+		found := false
+		for _, p := range v2Projects {
 			if strings.EqualFold(projectName, p.Title) {
-				// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER
-				// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER
-				var path string
 				pathParts := strings.Split(p.ResourcePath, "/")
+				var path string
 				if pathParts[1] == "orgs" || pathParts[1] == "users" {
 					path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
 				} else {
@@ -821,10 +841,12 @@ func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []str
 				break
 			}
 		}
+
 		if !found {
 			return nil, fmt.Errorf("'%s' not found", projectName)
 		}
 	}
+
 	return paths, nil
 }
 
@@ -863,7 +885,8 @@ type RepoMetadataInput struct {
 	Assignees  bool
 	Reviewers  bool
 	Labels     bool
-	Projects   bool
+	ProjectsV1 bool
+	ProjectsV2 bool
 	Milestones bool
 }
 
@@ -882,6 +905,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			return err
 		})
 	}
+
 	if input.Reviewers {
 		g.Go(func() error {
 			teams, err := OrganizationTeams(client, repo)
@@ -894,6 +918,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			return nil
 		})
 	}
+
 	if input.Reviewers {
 		g.Go(func() error {
 			login, err := CurrentLoginName(client, repo.RepoHost())
@@ -904,6 +929,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			return err
 		})
 	}
+
 	if input.Labels {
 		g.Go(func() error {
 			labels, err := RepoLabels(client, repo)
@@ -914,13 +940,23 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			return err
 		})
 	}
-	if input.Projects {
+
+	if input.ProjectsV1 {
+		g.Go(func() error {
+			var err error
+			result.Projects, err = v1Projects(client, repo)
+			return err
+		})
+	}
+
+	if input.ProjectsV2 {
 		g.Go(func() error {
 			var err error
-			result.Projects, result.ProjectsV2, err = relevantProjects(client, repo)
+			result.ProjectsV2, err = v2Projects(client, repo)
 			return err
 		})
 	}
+
 	if input.Milestones {
 		g.Go(func() error {
 			milestones, err := RepoMilestones(client, repo, "open")
@@ -943,7 +979,8 @@ type RepoResolveInput struct {
 	Assignees  []string
 	Reviewers  []string
 	Labels     []string
-	Projects   []string
+	ProjectsV1 bool
+	ProjectsV2 bool
 	Milestones []string
 }
 
@@ -970,7 +1007,8 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
 
 	// there is no way to look up projects nor milestones by name, so preload them all
 	mi := RepoMetadataInput{
-		Projects:   len(input.Projects) > 0,
+		ProjectsV1: input.ProjectsV1,
+		ProjectsV2: input.ProjectsV2,
 		Milestones: len(input.Milestones) > 0,
 	}
 	result, err := RepoMetadata(client, repo, mi)
@@ -1237,26 +1275,12 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
 	return milestones, nil
 }
 
-func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
-	projects, projectsV2, err := relevantProjects(client, repo)
-	if err != nil {
-		return nil, err
-	}
-	return ProjectsToPaths(projects, projectsV2, projectNames)
-}
-
-// RelevantProjects retrieves set of Projects and ProjectsV2 relevant to given repository:
+// v1Projects retrieves set of RepoProjects relevant to given repository:
 // - Projects for repository
 // - Projects for repository organization, if it belongs to one
-// - ProjectsV2 owned by current user
-// - ProjectsV2 linked to repository
-// - ProjectsV2 owned by repository organization, if it belongs to one
-func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) {
+func v1Projects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
 	var repoProjects []RepoProject
 	var orgProjects []RepoProject
-	var userProjectsV2 []ProjectV2
-	var repoProjectsV2 []ProjectV2
-	var orgProjectsV2 []ProjectV2
 
 	g, _ := errgroup.WithContext(context.Background())
 
@@ -1268,6 +1292,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 		}
 		return err
 	})
+
 	g.Go(func() error {
 		var err error
 		orgProjects, err = OrganizationProjects(client, repo)
@@ -1277,6 +1302,29 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 		}
 		return nil
 	})
+
+	if err := g.Wait(); err != nil {
+		return nil, err
+	}
+
+	projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects))
+	projects = append(projects, repoProjects...)
+	projects = append(projects, orgProjects...)
+
+	return projects, nil
+}
+
+// v2Projects retrieves set of ProjectV2 relevant to given repository:
+// - ProjectsV2 owned by current user
+// - ProjectsV2 linked to repository
+// - ProjectsV2 owned by repository organization, if it belongs to one
+func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) {
+	var userProjectsV2 []ProjectV2
+	var repoProjectsV2 []ProjectV2
+	var orgProjectsV2 []ProjectV2
+
+	g, _ := errgroup.WithContext(context.Background())
+
 	g.Go(func() error {
 		var err error
 		userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost())
@@ -1286,6 +1334,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 		}
 		return nil
 	})
+
 	g.Go(func() error {
 		var err error
 		repoProjectsV2, err = RepoProjectsV2(client, repo)
@@ -1295,6 +1344,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 		}
 		return nil
 	})
+
 	g.Go(func() error {
 		var err error
 		orgProjectsV2, err = OrganizationProjectsV2(client, repo)
@@ -1308,13 +1358,9 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 	})
 
 	if err := g.Wait(); err != nil {
-		return nil, nil, err
+		return nil, err
 	}
 
-	projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects))
-	projects = append(projects, repoProjects...)
-	projects = append(projects, orgProjects...)
-
 	// ProjectV2 might appear across multiple queries so use a map to keep them deduplicated.
 	m := make(map[string]ProjectV2, len(userProjectsV2)+len(repoProjectsV2)+len(orgProjectsV2))
 	for _, p := range userProjectsV2 {
@@ -1331,7 +1377,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
 		projectsV2 = append(projectsV2, p)
 	}
 
-	return projects, projectsV2, nil
+	return projectsV2, nil
 }
 
 func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) {
diff --git a/api/query_builder.go b/api/query_builder.go
index 2112367e3..4c45da3c1 100644
--- a/api/query_builder.go
+++ b/api/query_builder.go
@@ -132,6 +132,25 @@ var prCommits = shortenQuery(`
 	}
 `)
 
+var prClosingIssuesReferences = shortenQuery(`
+	closingIssuesReferences(first: 100) {
+		nodes {
+			id,
+			number,
+			url,
+			repository {
+				id,
+				name,
+				owner {
+					id,
+					login
+				}
+			}
+		}
+		pageInfo{hasNextPage,endCursor}
+	}
+`)
+
 var autoMergeRequest = shortenQuery(`
 	autoMergeRequest {
 		authorEmail,
@@ -287,6 +306,7 @@ var PullRequestFields = append(sharedIssuePRFields,
 	"baseRefName",
 	"baseRefOid",
 	"changedFiles",
+	"closingIssuesReferences",
 	"commits",
 	"deletions",
 	"files",
@@ -366,6 +386,8 @@ func IssueGraphQL(fields []string) string {
 			q = append(q, StatusCheckRollupGraphQLWithoutCountByState(""))
 		case "statusCheckRollupWithCountByState": // pseudo-field
 			q = append(q, StatusCheckRollupGraphQLWithCountByState())
+		case "closingIssuesReferences":
+			q = append(q, prClosingIssuesReferences)
 		default:
 			q = append(q, field)
 		}
diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md
index ed9362d38..31f44f6ef 100644
--- a/docs/release-process-deep-dive.md
+++ b/docs/release-process-deep-dive.md
@@ -428,9 +428,6 @@ Breaking this command down:
  * `/dlib` points to the previously extracted DLL
  * `/dmdf` points to the previously created metadata file
 
-> [!WARNING]
-> The [`GoReleaser` signing hook](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L43) can currently call `./script/sign` on a non-windows machine, but this is an artifact from pre-HSM that should be removed.
-
 ## <a id="release">[release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L250-L395)</a>
 
 <details>
diff --git a/git/client.go b/git/client.go
index 11a2e2e20..5f547c99c 100644
--- a/git/client.go
+++ b/git/client.go
@@ -381,7 +381,6 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte,
 // Downstream consumers of ReadBranchConfig should consider the behavior they desire if this errors,
 // as an empty config is not necessarily breaking.
 func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) {
-
 	prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
 	args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|pushremote|%s)$", prefix, MergeBaseConfig)}
 	cmd, err := c.Command(ctx, args...)
@@ -441,18 +440,50 @@ func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string
 	return err
 }
 
+// PushDefault defines the action git push should take if no refspec is given.
+// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault
+type PushDefault string
+
+const (
+	PushDefaultNothing  PushDefault = "nothing"
+	PushDefaultCurrent  PushDefault = "current"
+	PushDefaultUpstream PushDefault = "upstream"
+	PushDefaultTracking PushDefault = "tracking"
+	PushDefaultSimple   PushDefault = "simple"
+	PushDefaultMatching PushDefault = "matching"
+)
+
+func ParsePushDefault(s string) (PushDefault, error) {
+	validPushDefaults := map[string]struct{}{
+		string(PushDefaultNothing):  {},
+		string(PushDefaultCurrent):  {},
+		string(PushDefaultUpstream): {},
+		string(PushDefaultTracking): {},
+		string(PushDefaultSimple):   {},
+		string(PushDefaultMatching): {},
+	}
+
+	if _, ok := validPushDefaults[s]; ok {
+		return PushDefault(s), nil
+	}
+
+	return "", fmt.Errorf("unknown push.default value: %s", s)
+}
+
 // PushDefault returns the value of push.default in the config. If the value
 // is not set, it returns "simple" (the default git value). See
 // https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault
-func (c *Client) PushDefault(ctx context.Context) (string, error) {
+func (c *Client) PushDefault(ctx context.Context) (PushDefault, error) {
 	pushDefault, err := c.Config(ctx, "push.default")
 	if err == nil {
-		return pushDefault, nil
+		return ParsePushDefault(pushDefault)
 	}
 
+	// If there is an error that the config key is not set, return the default value
+	// that git uses since 2.0.
 	var gitError *GitError
 	if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 {
-		return "simple", nil
+		return PushDefaultSimple, nil
 	}
 	return "", err
 }
@@ -473,13 +504,89 @@ func (c *Client) RemotePushDefault(ctx context.Context) (string, error) {
 	return "", err
 }
 
-// ParsePushRevision gets the value of the @{push} revision syntax
+// RemoteTrackingRef is the structured form of the string "refs/remotes/<remote>/<branch>".
+// For example, the @{push} revision syntax could report "refs/remotes/origin/main" which would
+// be parsed into RemoteTrackingRef{Remote: "origin", Branch: "main"}.
+type RemoteTrackingRef struct {
+	Remote string
+	Branch string
+}
+
+func (r RemoteTrackingRef) String() string {
+	return fmt.Sprintf("refs/remotes/%s/%s", r.Remote, r.Branch)
+}
+
+// ParseRemoteTrackingRef parses a string of the form "refs/remotes/<remote>/<branch>" into
+// a RemoteTrackingBranch struct. If the string does not match this format, an error is returned.
+//
+// For now, we assume that refnames are of the format "<remote>/<branch>", where
+// the remote is a single path component, and branch may have many path components e.g.
+// "origin/my/branch" is valid as: {Remote: "origin", Branch: "my/branch"}
+// but "my/origin/branch" would parse incorrectly as: {Remote: "my", Branch: "origin/branch"}
+// I don't believe there is a way to fix this without providing the list of remotes to this function.
+//
+// It becomes particularly confusing if you have something like:
+//
+// ```
+// [remote "foo"]
+// 	url = https://github.com/williammartin/test-repo.git
+// 	fetch = +refs/heads/*:refs/remotes/foo/*
+// [remote "foo/bar"]
+// 	url = https://github.com/williammartin/test-repo.git
+// 	fetch = +refs/heads/*:refs/remotes/foo/bar/*
+// [branch "bar/baz"]
+// 	remote = foo
+// 	merge = refs/heads/bar/baz
+// [branch "baz"]
+// 	remote = foo/bar
+// 	merge = refs/heads/baz
+// ```
+//
+// These @{push} refs would resolve identically:
+//
+// ```
+// ➜ git rev-parse --symbolic-full-name baz@{push}
+// refs/remotes/foo/bar/baz
+
+// ➜ git rev-parse --symbolic-full-name bar/baz@{push}
+// refs/remotes/foo/bar/baz
+// ```
+//
+// When using this ref, git assumes it means `remote: foo` `branch: bar/baz`.
+func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) {
+	prefix := "refs/remotes/"
+	if !strings.HasPrefix(s, prefix) {
+		return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: %s", s)
+	}
+
+	refName := strings.TrimPrefix(s, prefix)
+	refNameParts := strings.SplitN(refName, "/", 2)
+	if len(refNameParts) != 2 {
+		return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: %s", s)
+	}
+
+	return RemoteTrackingRef{
+		Remote: refNameParts[0],
+		Branch: refNameParts[1],
+	}, nil
+}
+
+// PushRevision gets the value of the @{push} revision syntax
 // An error here doesn't necessarily mean something is broken, but may mean that the @{push}
 // revision syntax couldn't be resolved, such as in non-centralized workflows with
 // push.default = simple. Downstream consumers should consider how to handle this error.
-func (c *Client) ParsePushRevision(ctx context.Context, branch string) (string, error) {
-	revParseOut, err := c.revParse(ctx, "--abbrev-ref", branch+"@{push}")
-	return firstLine(revParseOut), err
+func (c *Client) PushRevision(ctx context.Context, branch string) (RemoteTrackingRef, error) {
+	revParseOut, err := c.revParse(ctx, "--symbolic-full-name", branch+"@{push}")
+	if err != nil {
+		return RemoteTrackingRef{}, err
+	}
+
+	ref, err := ParseRemoteTrackingRef(firstLine(revParseOut))
+	if err != nil {
+		return RemoteTrackingRef{}, fmt.Errorf("could not parse push revision: %v", err)
+	}
+
+	return ref, nil
 }
 
 func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {
diff --git a/git/command.go b/git/command.go
index 8065ffd86..c4614d086 100644
--- a/git/command.go
+++ b/git/command.go
@@ -43,10 +43,21 @@ func (gc *Command) Output() ([]byte, error) {
 	out, err := run.PrepareCmd(gc.Cmd).Output()
 	if err != nil {
 		ge := GitError{err: err}
+
+		// In real implementation, this should be an exec.ExitError, as below,
+		// but the tests use a different type because exec.ExitError are difficult
+		// to create. We want to get the exit code and stderr, but stderr
+		// is not a method and so tests can't access it.
+		// THIS MEANS THAT TESTS WILL NOT CORRECTLY HAVE STDERR SET,
+		// but at least tests can get the exit code.
+		var exitErrorWithExitCode errWithExitCode
+		if errors.As(err, &exitErrorWithExitCode) {
+			ge.ExitCode = exitErrorWithExitCode.ExitCode()
+		}
+
 		var exitError *exec.ExitError
 		if errors.As(err, &exitError) {
 			ge.Stderr = string(exitError.Stderr)
-			ge.ExitCode = exitError.ExitCode()
 		}
 		err = &ge
 	}
diff --git a/go.mod b/go.mod
index 9d1afbc38..3562f24a6 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,13 @@ toolchain go1.23.5
 require (
 	github.com/AlecAivazis/survey/v2 v2.3.7
 	github.com/MakeNowJust/heredoc v1.0.0
+	github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
 	github.com/briandowns/spinner v1.18.1
 	github.com/cenkalti/backoff/v4 v4.3.0
-	github.com/charmbracelet/glamour v0.8.0
-	github.com/charmbracelet/lipgloss v0.12.1
-	github.com/cli/go-gh/v2 v2.11.2
+	github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3
+	github.com/charmbracelet/huh v0.7.0
+	github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
+	github.com/cli/go-gh/v2 v2.12.0
 	github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
 	github.com/cli/oauth v1.1.1
 	github.com/cli/safeexec v1.0.1
@@ -22,13 +24,14 @@ require (
 	github.com/gabriel-vasile/mimetype v1.4.8
 	github.com/gdamore/tcell/v2 v2.5.4
 	github.com/golang/snappy v0.0.4
-	github.com/google/go-cmp v0.6.0
+	github.com/google/go-cmp v0.7.0
 	github.com/google/go-containerregistry v0.20.3
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/gorilla/websocket v1.5.3
 	github.com/hashicorp/go-multierror v1.1.1
 	github.com/hashicorp/go-version v1.3.0
 	github.com/henvic/httpretty v0.1.4
+	github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
 	github.com/in-toto/attestation v1.1.1
 	github.com/joho/godotenv v1.5.1
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@@ -40,30 +43,42 @@ require (
 	github.com/opentracing/opentracing-go v1.2.0
 	github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
 	github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc
-	github.com/sigstore/protobuf-specs v0.3.3
-	github.com/sigstore/sigstore-go v0.7.0
-	github.com/spf13/cobra v1.8.1
+	github.com/sigstore/protobuf-specs v0.4.1
+	github.com/sigstore/sigstore-go v0.7.2
+	github.com/spf13/cobra v1.9.1
 	github.com/spf13/pflag v1.0.6
 	github.com/stretchr/testify v1.10.0
 	github.com/zalando/go-keyring v0.2.5
-	golang.org/x/crypto v0.32.0
-	golang.org/x/sync v0.10.0
-	golang.org/x/term v0.28.0
-	golang.org/x/text v0.21.0
-	google.golang.org/grpc v1.69.4
-	google.golang.org/protobuf v1.36.5
+	golang.org/x/crypto v0.37.0
+	golang.org/x/sync v0.13.0
+	golang.org/x/term v0.31.0
+	golang.org/x/text v0.24.0
+	google.golang.org/grpc v1.71.1
+	google.golang.org/protobuf v1.36.6
 	gopkg.in/h2non/gock.v1 v1.1.2
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
+	dario.cat/mergo v1.0.1 // indirect
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver/v3 v3.3.0 // indirect
+	github.com/Masterminds/sprig/v3 v3.3.0 // indirect
 	github.com/alecthomas/chroma/v2 v2.14.0 // indirect
 	github.com/alessio/shellescape v1.4.2 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/blang/semver v3.5.1+incompatible // indirect
-	github.com/charmbracelet/x/ansi v0.1.4 // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
+	github.com/charmbracelet/bubbles v0.21.0 // indirect
+	github.com/charmbracelet/bubbletea v1.3.4 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.8.0 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/cli/browser v1.3.0 // indirect
 	github.com/cli/shurcooL-graphql v0.0.4 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
@@ -75,30 +90,33 @@ require (
 	github.com/docker/cli v27.5.0+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.8.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/fatih/color v1.16.0 // indirect
-	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/fsnotify/fsnotify v1.8.0 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/go-chi/chi v4.1.2+incompatible // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-openapi/analysis v0.23.0 // indirect
-	github.com/go-openapi/errors v0.22.0 // indirect
+	github.com/go-openapi/errors v0.22.1 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/jsonreference v0.21.0 // indirect
 	github.com/go-openapi/loads v0.22.0 // indirect
 	github.com/go-openapi/runtime v0.28.0 // indirect
 	github.com/go-openapi/spec v0.21.0 // indirect
 	github.com/go-openapi/strfmt v0.23.0 // indirect
-	github.com/go-openapi/swag v0.23.0 // indirect
+	github.com/go-openapi/swag v0.23.1 // indirect
 	github.com/go-openapi/validate v0.24.0 // indirect
+	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/google/certificate-transparency-go v1.3.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
-	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/huandu/xstrings v1.5.0 // indirect
 	github.com/in-toto/in-toto-golang v0.9.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/itchyny/gojq v0.12.15 // indirect
@@ -108,37 +126,42 @@ require (
 	github.com/klauspost/compress v1.17.11 // indirect
 	github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-	github.com/magiconair/properties v1.8.7 // indirect
-	github.com/mailru/easyjson v0.7.7 // indirect
-	github.com/mattn/go-runewidth v0.0.15 // indirect
+	github.com/mailru/easyjson v0.9.0 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
-	github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/oklog/ulid v1.3.1 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.0 // indirect
-	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rodaine/table v1.0.1 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/sagikazarmark/locafero v0.4.0 // indirect
-	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/sassoftware/relic v7.2.1+incompatible // indirect
 	github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect
 	github.com/shibumi/go-pathspec v1.3.0 // indirect
+	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
-	github.com/sigstore/rekor v1.3.8 // indirect
-	github.com/sigstore/sigstore v1.8.12 // indirect
-	github.com/sigstore/timestamp-authority v1.2.4 // indirect
+	github.com/sigstore/rekor v1.3.9 // indirect
+	github.com/sigstore/sigstore v1.9.1 // indirect
+	github.com/sigstore/timestamp-authority v1.2.5 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/sourcegraph/conc v0.3.0 // indirect
-	github.com/spf13/afero v1.11.0 // indirect
-	github.com/spf13/cast v1.7.0 // indirect
-	github.com/spf13/viper v1.19.0 // indirect
+	github.com/spf13/afero v1.12.0 // indirect
+	github.com/spf13/cast v1.7.1 // indirect
+	github.com/spf13/viper v1.20.1 // indirect
 	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/theupdateframework/go-tuf v0.7.0 // indirect
@@ -147,22 +170,22 @@ require (
 	github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
 	github.com/transparency-dev/merkle v0.0.2 // indirect
 	github.com/vbatts/tar-split v0.11.6 // indirect
-	github.com/yuin/goldmark v1.7.4 // indirect
-	github.com/yuin/goldmark-emoji v1.0.3 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yuin/goldmark v1.7.8 // indirect
+	github.com/yuin/goldmark-emoji v1.0.5 // indirect
 	go.mongodb.org/mongo-driver v1.14.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
-	go.opentelemetry.io/otel v1.33.0 // indirect
-	go.opentelemetry.io/otel/metric v1.33.0 // indirect
-	go.opentelemetry.io/otel/trace v1.33.0 // indirect
+	go.opentelemetry.io/otel v1.34.0 // indirect
+	go.opentelemetry.io/otel/metric v1.34.0 // indirect
+	go.opentelemetry.io/otel/trace v1.34.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
 	golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
-	golang.org/x/mod v0.22.0 // indirect
-	golang.org/x/net v0.34.0 // indirect
-	golang.org/x/sys v0.29.0 // indirect
+	golang.org/x/mod v0.24.0 // indirect
+	golang.org/x/net v0.38.0 // indirect
+	golang.org/x/sys v0.32.0 // indirect
 	golang.org/x/tools v0.29.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect
-	gopkg.in/ini.v1 v1.67.0 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 2c14dc4c3..2ac25c2f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,38 +1,45 @@
-cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
-cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
-cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
-cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
-cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
-cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg=
+cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
+cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
+cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
+cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
+cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
+cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
 cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
 cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
-cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA=
-cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
-cloud.google.com/go/kms v1.20.4 h1:CJ0hMpOg1ANN9tx/a/GPJ+Uxudy8k6f3fvGFuTHiE5A=
-cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc=
-cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc=
-cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
+cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
+cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
+cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk=
+cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE=
+cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q=
+cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY=
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
 github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
 github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
 github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
-github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E=
-github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE=
-github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw=
-github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
+github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
+github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
 github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
@@ -45,36 +52,38 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u
 github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
-github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
-github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
-github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo=
-github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
-github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg=
-github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE=
-github.com/aws/aws-sdk-go-v2/service/kms v1.37.8 h1:KbLZjYqhQ9hyB4HwXiheiflTlYQa0+Fz0Ms/rh5f3mk=
-github.com/aws/aws-sdk-go-v2/service/kms v1.37.8/go.mod h1:ANs9kBhK4Ghj9z1W+bsr3WsNaPF71qkgd6eE6Ekol/Y=
-github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8=
-github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY=
-github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM=
-github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
-github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
-github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
+github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
+github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
+github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
+github.com/aws/aws-sdk-go-v2/config v1.29.10 h1:yNjgjiGBp4GgaJrGythyBXg2wAs+Im9fSWIUwvi1CAc=
+github.com/aws/aws-sdk-go-v2/config v1.29.10/go.mod h1:A0mbLXSdtob/2t59n1X0iMkPQ5d+YzYZB4rwu7SZ7aA=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.63 h1:rv1V3kIJ14pdmTu01hwcMJ0WAERensSiD9rEWEBb1Tk=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.63/go.mod h1:EJj+yDf0txT26Ulo0VWTavBl31hOsaeuMxIHu2m0suY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
+github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc=
+github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0=
+github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2 h1:wK8O+j2dOolmpNVY1EWIbLgxrGCHJKVPm08Hv/u80M8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
+github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
+github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -87,23 +96,47 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
 github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
-github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
-github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
-github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
-github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
-github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4 h1:6KzMkQeAF56rggw2NZu1L+TH7j9+DM1/2Kmh7KUxg1I=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
+github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g=
+github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s=
+github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
+github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
 github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
 github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
-github.com/cli/go-gh/v2 v2.11.2 h1:oad1+sESTPNTiTvh3I3t8UmxuovNDxhwLzeMHk45Q9w=
-github.com/cli/go-gh/v2 v2.11.2/go.mod h1:vVFhi3TfjseIW26ED9itAR8gQK0aVThTm8sYrsZ5QTI=
+github.com/cli/go-gh/v2 v2.12.0 h1:PIurZ13fXbWDbr2//6ws4g4zDbryO+iDuTpiHgiV+6k=
+github.com/cli/go-gh/v2 v2.12.0/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E=
 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk=
 github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs=
@@ -117,7 +150,6 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
 github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
 github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@@ -146,6 +178,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@@ -153,8 +189,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
@@ -163,8 +199,6 @@ github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/
 github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
 github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
 github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
-github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
-github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
 github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -174,8 +208,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
 github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
-github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
-github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
+github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU=
+github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
@@ -188,32 +222,34 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
 github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
 github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
 github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
-github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
-github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
+github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
 github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
 github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go=
 github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI=
 github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
@@ -222,8 +258,8 @@ github.com/google/trillian v1.7.1 h1:+zX8jLM3524bAMPS+VxaDIDgsMv3/ty6DuLWerHXcek
 github.com/google/trillian v1.7.1/go.mod h1:E1UMAHqpZCA8AQdrKdWmHmtUfSeiD0sDWD1cv00Xa+c=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
 github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
 github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -251,13 +287,12 @@ github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0S
 github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
 github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
 github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
-github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
+github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4=
+github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA=
 github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
 github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -266,6 +301,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u
 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
 github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM=
 github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/in-toto/attestation v1.1.1 h1:QD3d+oATQ0dFsWoNh5oT0udQ3tUrOsZZ0Fc3tSgWbzI=
 github.com/in-toto/attestation v1.1.1/go.mod h1:Dcq1zVwA2V7Qin8I7rgOi+i837wEf/mOZwRm047Sjys=
 github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
@@ -308,25 +345,27 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
 github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ=
 github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -334,14 +373,24 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/microsoft/dev-tunnels v0.0.25 h1:UlMKUI+2O8cSu4RlB52ioSyn1LthYSVkJA+CSTsdKoA=
 github.com/microsoft/dev-tunnels v0.0.25/go.mod h1:frU++12T/oqxckXkDpTuYa427ncguEOodSPZcGCCrzQ=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
-github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
 github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -356,8 +405,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
 github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -365,12 +414,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
-github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
+github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
 github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
 github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
-github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0=
@@ -387,10 +436,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
+github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
 github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A=
 github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
 github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
@@ -401,55 +448,50 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
 github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
 github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
 github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0=
 github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
-github.com/sigstore/protobuf-specs v0.3.3 h1:RMZQgXTD/pF7KW6b5NaRLYxFYZ/wzx44PQFXN2PEo5g=
-github.com/sigstore/protobuf-specs v0.3.3/go.mod h1:vIhZ6Uor1a38+wvRrKcqL2PtYNlgoIW9lhzYzkyy4EU=
-github.com/sigstore/rekor v1.3.8 h1:B8kJI8mpSIXova4Jxa6vXdJyysRxFGsEsLKBDl0rRjA=
-github.com/sigstore/rekor v1.3.8/go.mod h1:/dHFYKSuxEygfDRnEwyJ+ZD6qoVYNXQdi1mJrKvKWsI=
-github.com/sigstore/sigstore v1.8.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc=
-github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo=
-github.com/sigstore/sigstore-go v0.7.0 h1:bIGPc2IbnbxnzlqQcKlh1o96bxVJ4yRElpP1gHrOH48=
-github.com/sigstore/sigstore-go v0.7.0/go.mod h1:4RrCK+i+jhx7lyOG2Vgef0/kFLbKlDI1hrioUYvkxxA=
-github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE=
-github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12/go.mod h1:aw60vs3crnQdM/DYH+yF2P0MVKtItwAX34nuaMrY7Lk=
-github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12 h1:FPpliDTywSy0woLHMAdmTSZ5IS/lVBZ0dY0I+2HmnSY=
-github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12/go.mod h1:NkPiz4XA0JcBSXzJUrjMj7Xi7oSTew1Ip3Zmt56mHlw=
-github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12 h1:kweBChR6M9FEvmxN3BMEcl7SNnwxTwKF7THYFKLOE5U=
-github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12/go.mod h1:6+d+A6oYt1W5OgtzgEVb21V7tAZ/C2Ihtzc5MNJbayY=
-github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12 h1:jvY1B9bjP+tKzdKDyuq5K7O19CG2IKzGJNTy5tuL2Gs=
-github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12/go.mod h1:2uEeOb8xE2RC6OvzxKux1wkS39Zv8gA27z92m49xUTc=
-github.com/sigstore/timestamp-authority v1.2.4 h1:RjXZxOWorEiem/uSr0pFHVtQpyzpcFxgugo5jVqm3mw=
-github.com/sigstore/timestamp-authority v1.2.4/go.mod h1:ExrbobKdEuwuBptZIiKp1IaVBRiUeKbiuSyZTO8Okik=
+github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc=
+github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
+github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU=
+github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM=
+github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw=
+github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4=
+github.com/sigstore/sigstore-go v0.7.2 h1:CN4xPasChSEb0QBMxMW5dLcXdA9KD4QiRyVnMkhXj6U=
+github.com/sigstore/sigstore-go v0.7.2/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E=
+github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4=
+github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM=
+github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc=
+github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1/go.mod h1:d5m5LOa/69a+t2YC9pDPwS1n2i/PhqB4cUKbpVDlKKE=
+github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1 h1:LFiYK1DEWQ6Hf/nroFzBMM+s5rVSjVL45Alpb5Ctl5A=
+github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1/go.mod h1:GFyFmDsE2wDuIHZD+4+JErGpA0S4zJsKNz5l2JVJd8s=
+github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1 h1:sIW6xe4yU5eIMH8fve2C78d+r29KmHnIb+7po+80bsY=
+github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1/go.mod h1:3pNf99GnK9eu3XUa5ebHzgEQSVYf9hqAoPFwbwD6O6M=
+github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw=
+github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
 github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
-github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
+github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
-github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
+github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -460,40 +502,48 @@ github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if37
 github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA=
 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
+github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI=
+github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis=
+github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0=
+github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw=
+github.com/tink-crypto/tink-go/v2 v2.3.0 h1:4/TA0lw0lA/iVKBL9f8R5eP7397bfc4antAMXF5JRhs=
+github.com/tink-crypto/tink-go/v2 v2.3.0/go.mod h1:kfPOtXIadHlekBTeBtJrHWqoGL+Fm3JQg0wtltPuxLU=
 github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
 github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
 github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
 github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A=
 github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs=
 github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
-github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
-github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
 github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
 github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
 go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
 go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
-go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
-go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
-go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
-go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
-go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
-go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
-go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
-go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
-go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
-go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
-go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU=
-go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
+go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
+go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
+go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
+go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
+go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
+go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
+go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
+go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
+go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
+go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
+go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw=
+go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -502,73 +552,72 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
-golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
-golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
+golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
-golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
+golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
-golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
-golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
 golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY=
-google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI=
-google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
-google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
-google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
-google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
-google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
-google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
-google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc=
+google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ=
+google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
+google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
+google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
+google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
+google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
+google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
 gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/config.go b/internal/config/config.go
index 476154d66..003a0ca17 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,17 +14,23 @@ import (
 	ghConfig "github.com/cli/go-gh/v2/pkg/config"
 )
 
+// Important: some of the following configuration settings are used outside of `cli/cli`,
+// they are defined here to avoid `cli/cli` being changed unexpectedly.
 const (
+	accessibleColorsKey   = "accessible_colors" // used by cli/go-gh to enable the use of customizable, accessible 4-bit colors.
+	accessiblePrompterKey = "accessible_prompter"
 	aliasesKey            = "aliases"
-	browserKey            = "browser"
-	editorKey             = "editor"
+	browserKey            = "browser" // used by cli/go-gh to open URLs in web browsers
+	colorLabelsKey        = "color_labels"
+	editorKey             = "editor" // used by cli/go-gh to open interactive text editor
 	gitProtocolKey        = "git_protocol"
-	hostsKey              = "hosts"
+	hostsKey              = "hosts" // used by cli/go-gh to locate authenticated host tokens
 	httpUnixSocketKey     = "http_unix_socket"
-	oauthTokenKey         = "oauth_token"
+	oauthTokenKey         = "oauth_token" // used by cli/go-gh to locate authenticated host tokens
 	pagerKey              = "pager"
 	promptKey             = "prompt"
 	preferEditorPromptKey = "prefer_editor_prompt"
+	spinnerKey            = "spinner"
 	userKey               = "user"
 	usersKey              = "users"
 	versionKey            = "version"
@@ -108,11 +114,26 @@ func (c *cfg) Authentication() gh.AuthConfig {
 	return &AuthConfig{cfg: c.cfg}
 }
 
+func (c *cfg) AccessibleColors(hostname string) gh.ConfigEntry {
+	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
+	return c.GetOrDefault(hostname, accessibleColorsKey).Unwrap()
+}
+
+func (c *cfg) AccessiblePrompter(hostname string) gh.ConfigEntry {
+	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
+	return c.GetOrDefault(hostname, accessiblePrompterKey).Unwrap()
+}
+
 func (c *cfg) Browser(hostname string) gh.ConfigEntry {
 	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
 	return c.GetOrDefault(hostname, browserKey).Unwrap()
 }
 
+func (c *cfg) ColorLabels(hostname string) gh.ConfigEntry {
+	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
+	return c.GetOrDefault(hostname, colorLabelsKey).Unwrap()
+}
+
 func (c *cfg) Editor(hostname string) gh.ConfigEntry {
 	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
 	return c.GetOrDefault(hostname, editorKey).Unwrap()
@@ -143,6 +164,11 @@ func (c *cfg) PreferEditorPrompt(hostname string) gh.ConfigEntry {
 	return c.GetOrDefault(hostname, preferEditorPromptKey).Unwrap()
 }
 
+func (c *cfg) Spinner(hostname string) gh.ConfigEntry {
+	// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
+	return c.GetOrDefault(hostname, spinnerKey).Unwrap()
+}
+
 func (c *cfg) Version() o.Option[string] {
 	return c.get("", versionKey)
 }
@@ -532,6 +558,14 @@ aliases:
 http_unix_socket:
 # What web browser gh should use when opening URLs. If blank, will refer to environment.
 browser:
+# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
+color_labels: disabled
+# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
+accessible_colors: disabled
+# Whether an accessible prompter should be used. Supported values: enabled, disabled
+accessible_prompter: disabled
+# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
+spinner: enabled
 `
 
 type ConfigOption struct {
@@ -602,6 +636,42 @@ var Options = []ConfigOption{
 			return c.Browser(hostname).Value
 		},
 	},
+	{
+		Key:           colorLabelsKey,
+		Description:   "whether to display labels using their RGB hex color codes in terminals that support truecolor",
+		DefaultValue:  "disabled",
+		AllowedValues: []string{"enabled", "disabled"},
+		CurrentValue: func(c gh.Config, hostname string) string {
+			return c.ColorLabels(hostname).Value
+		},
+	},
+	{
+		Key:           accessibleColorsKey,
+		Description:   "whether customizable, 4-bit accessible colors should be used",
+		DefaultValue:  "disabled",
+		AllowedValues: []string{"enabled", "disabled"},
+		CurrentValue: func(c gh.Config, hostname string) string {
+			return c.AccessibleColors(hostname).Value
+		},
+	},
+	{
+		Key:           accessiblePrompterKey,
+		Description:   "whether an accessible prompter should be used",
+		DefaultValue:  "disabled",
+		AllowedValues: []string{"enabled", "disabled"},
+		CurrentValue: func(c gh.Config, hostname string) string {
+			return c.AccessiblePrompter(hostname).Value
+		},
+	},
+	{
+		Key:           spinnerKey,
+		Description:   "whether to use a animated spinner as a progress indicator",
+		DefaultValue:  "enabled",
+		AllowedValues: []string{"enabled", "disabled"},
+		CurrentValue: func(c gh.Config, hostname string) string {
+			return c.Spinner(hostname).Value
+		},
+	},
 }
 
 func HomeDirPath(subdir string) (string, error) {
diff --git a/internal/config/stub.go b/internal/config/stub.go
index 71d44556d..ea60254db 100644
--- a/internal/config/stub.go
+++ b/internal/config/stub.go
@@ -52,9 +52,18 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
 			},
 		}
 	}
+	mock.AccessibleColorsFunc = func(hostname string) gh.ConfigEntry {
+		return cfg.AccessibleColors(hostname)
+	}
+	mock.AccessiblePrompterFunc = func(hostname string) gh.ConfigEntry {
+		return cfg.AccessiblePrompter(hostname)
+	}
 	mock.BrowserFunc = func(hostname string) gh.ConfigEntry {
 		return cfg.Browser(hostname)
 	}
+	mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry {
+		return cfg.ColorLabels(hostname)
+	}
 	mock.EditorFunc = func(hostname string) gh.ConfigEntry {
 		return cfg.Editor(hostname)
 	}
@@ -73,6 +82,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
 	mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry {
 		return cfg.PreferEditorPrompt(hostname)
 	}
+	mock.SpinnerFunc = func(hostname string) gh.ConfigEntry {
+		return cfg.Spinner(hostname)
+	}
 	mock.VersionFunc = func() o.Option[string] {
 		return cfg.Version()
 	}
diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go
index 6f36dd3fc..6f760f209 100644
--- a/internal/featuredetection/detector_mock.go
+++ b/internal/featuredetection/detector_mock.go
@@ -1,5 +1,7 @@
 package featuredetection
 
+import "github.com/cli/cli/v2/internal/gh"
+
 type DisabledDetectorMock struct{}
 
 func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@@ -14,6 +16,10 @@ func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error)
 	return RepositoryFeatures{}, nil
 }
 
+func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
+	return gh.ProjectsV1Unsupported
+}
+
 type EnabledDetectorMock struct{}
 
 func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@@ -27,3 +33,7 @@ func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error
 func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) {
 	return allRepositoryFeatures, nil
 }
+
+func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
+	return gh.ProjectsV1Supported
+}
diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go
index a9bbe25f8..fba317f58 100644
--- a/internal/featuredetection/feature_detection.go
+++ b/internal/featuredetection/feature_detection.go
@@ -4,6 +4,7 @@ import (
 	"net/http"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
 	"golang.org/x/sync/errgroup"
 
 	ghauth "github.com/cli/go-gh/v2/pkg/auth"
@@ -13,6 +14,7 @@ type Detector interface {
 	IssueFeatures() (IssueFeatures, error)
 	PullRequestFeatures() (PullRequestFeatures, error)
 	RepositoryFeatures() (RepositoryFeatures, error)
+	ProjectsV1() gh.ProjectsV1Support
 }
 
 type IssueFeatures struct {
@@ -199,3 +201,13 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
 
 	return features, nil
 }
+
+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) {
+		return gh.ProjectsV1Supported
+	}
+
+	return gh.ProjectsV1Unsupported
+}
diff --git a/internal/gh/gh.go b/internal/gh/gh.go
index 8e640c41a..aa90a5268 100644
--- a/internal/gh/gh.go
+++ b/internal/gh/gh.go
@@ -35,8 +35,14 @@ type Config interface {
 	// Set provides primitive access for setting configuration values, optionally scoped by host.
 	Set(hostname string, key string, value string)
 
+	// AccessibleColors returns the configured accessible_colors setting, optionally scoped by host.
+	AccessibleColors(hostname string) ConfigEntry
+	// AccessiblePrompter returns the configured accessible_prompter setting, optionally scoped by host.
+	AccessiblePrompter(hostname string) ConfigEntry
 	// Browser returns the configured browser, optionally scoped by host.
 	Browser(hostname string) ConfigEntry
+	// ColorLabels returns the configured color_label setting, optionally scoped by host.
+	ColorLabels(hostname string) ConfigEntry
 	// Editor returns the configured editor, optionally scoped by host.
 	Editor(hostname string) ConfigEntry
 	// GitProtocol returns the configured git protocol, optionally scoped by host.
@@ -49,6 +55,8 @@ type Config interface {
 	Prompt(hostname string) ConfigEntry
 	// PreferEditorPrompt returns the configured editor-based prompt, optionally scoped by host.
 	PreferEditorPrompt(hostname string) ConfigEntry
+	// Spinner returns the configured spinner setting, optionally scoped by host.
+	Spinner(hostname string) ConfigEntry
 
 	// Aliases provides persistent storage and modification of command aliases.
 	Aliases() AliasConfig
diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go
index 569af1fac..9f3f80799 100644
--- a/internal/gh/mock/config.go
+++ b/internal/gh/mock/config.go
@@ -19,6 +19,12 @@ var _ gh.Config = &ConfigMock{}
 //
 //		// make and configure a mocked gh.Config
 //		mockedConfig := &ConfigMock{
+//			AccessibleColorsFunc: func(hostname string) gh.ConfigEntry {
+//				panic("mock out the AccessibleColors method")
+//			},
+//			AccessiblePrompterFunc: func(hostname string) gh.ConfigEntry {
+//				panic("mock out the AccessiblePrompter method")
+//			},
 //			AliasesFunc: func() gh.AliasConfig {
 //				panic("mock out the Aliases method")
 //			},
@@ -31,6 +37,9 @@ var _ gh.Config = &ConfigMock{}
 //			CacheDirFunc: func() string {
 //				panic("mock out the CacheDir method")
 //			},
+//			ColorLabelsFunc: func(hostname string) gh.ConfigEntry {
+//				panic("mock out the ColorLabels method")
+//			},
 //			EditorFunc: func(hostname string) gh.ConfigEntry {
 //				panic("mock out the Editor method")
 //			},
@@ -58,6 +67,9 @@ var _ gh.Config = &ConfigMock{}
 //			SetFunc: func(hostname string, key string, value string)  {
 //				panic("mock out the Set method")
 //			},
+//			SpinnerFunc: func(hostname string) gh.ConfigEntry {
+//				panic("mock out the Spinner method")
+//			},
 //			VersionFunc: func() o.Option[string] {
 //				panic("mock out the Version method")
 //			},
@@ -71,6 +83,12 @@ var _ gh.Config = &ConfigMock{}
 //
 //	}
 type ConfigMock struct {
+	// AccessibleColorsFunc mocks the AccessibleColors method.
+	AccessibleColorsFunc func(hostname string) gh.ConfigEntry
+
+	// AccessiblePrompterFunc mocks the AccessiblePrompter method.
+	AccessiblePrompterFunc func(hostname string) gh.ConfigEntry
+
 	// AliasesFunc mocks the Aliases method.
 	AliasesFunc func() gh.AliasConfig
 
@@ -83,6 +101,9 @@ type ConfigMock struct {
 	// CacheDirFunc mocks the CacheDir method.
 	CacheDirFunc func() string
 
+	// ColorLabelsFunc mocks the ColorLabels method.
+	ColorLabelsFunc func(hostname string) gh.ConfigEntry
+
 	// EditorFunc mocks the Editor method.
 	EditorFunc func(hostname string) gh.ConfigEntry
 
@@ -110,6 +131,9 @@ type ConfigMock struct {
 	// SetFunc mocks the Set method.
 	SetFunc func(hostname string, key string, value string)
 
+	// SpinnerFunc mocks the Spinner method.
+	SpinnerFunc func(hostname string) gh.ConfigEntry
+
 	// VersionFunc mocks the Version method.
 	VersionFunc func() o.Option[string]
 
@@ -118,6 +142,16 @@ type ConfigMock struct {
 
 	// calls tracks calls to the methods.
 	calls struct {
+		// AccessibleColors holds details about calls to the AccessibleColors method.
+		AccessibleColors []struct {
+			// Hostname is the hostname argument value.
+			Hostname string
+		}
+		// AccessiblePrompter holds details about calls to the AccessiblePrompter method.
+		AccessiblePrompter []struct {
+			// Hostname is the hostname argument value.
+			Hostname string
+		}
 		// Aliases holds details about calls to the Aliases method.
 		Aliases []struct {
 		}
@@ -132,6 +166,11 @@ type ConfigMock struct {
 		// CacheDir holds details about calls to the CacheDir method.
 		CacheDir []struct {
 		}
+		// ColorLabels holds details about calls to the ColorLabels method.
+		ColorLabels []struct {
+			// Hostname is the hostname argument value.
+			Hostname string
+		}
 		// Editor holds details about calls to the Editor method.
 		Editor []struct {
 			// Hostname is the hostname argument value.
@@ -183,6 +222,11 @@ type ConfigMock struct {
 			// Value is the value argument value.
 			Value string
 		}
+		// Spinner holds details about calls to the Spinner method.
+		Spinner []struct {
+			// Hostname is the hostname argument value.
+			Hostname string
+		}
 		// Version holds details about calls to the Version method.
 		Version []struct {
 		}
@@ -190,10 +234,13 @@ type ConfigMock struct {
 		Write []struct {
 		}
 	}
+	lockAccessibleColors   sync.RWMutex
+	lockAccessiblePrompter sync.RWMutex
 	lockAliases            sync.RWMutex
 	lockAuthentication     sync.RWMutex
 	lockBrowser            sync.RWMutex
 	lockCacheDir           sync.RWMutex
+	lockColorLabels        sync.RWMutex
 	lockEditor             sync.RWMutex
 	lockGetOrDefault       sync.RWMutex
 	lockGitProtocol        sync.RWMutex
@@ -203,10 +250,75 @@ type ConfigMock struct {
 	lockPreferEditorPrompt sync.RWMutex
 	lockPrompt             sync.RWMutex
 	lockSet                sync.RWMutex
+	lockSpinner            sync.RWMutex
 	lockVersion            sync.RWMutex
 	lockWrite              sync.RWMutex
 }
 
+// AccessibleColors calls AccessibleColorsFunc.
+func (mock *ConfigMock) AccessibleColors(hostname string) gh.ConfigEntry {
+	if mock.AccessibleColorsFunc == nil {
+		panic("ConfigMock.AccessibleColorsFunc: method is nil but Config.AccessibleColors was just called")
+	}
+	callInfo := struct {
+		Hostname string
+	}{
+		Hostname: hostname,
+	}
+	mock.lockAccessibleColors.Lock()
+	mock.calls.AccessibleColors = append(mock.calls.AccessibleColors, callInfo)
+	mock.lockAccessibleColors.Unlock()
+	return mock.AccessibleColorsFunc(hostname)
+}
+
+// AccessibleColorsCalls gets all the calls that were made to AccessibleColors.
+// Check the length with:
+//
+//	len(mockedConfig.AccessibleColorsCalls())
+func (mock *ConfigMock) AccessibleColorsCalls() []struct {
+	Hostname string
+} {
+	var calls []struct {
+		Hostname string
+	}
+	mock.lockAccessibleColors.RLock()
+	calls = mock.calls.AccessibleColors
+	mock.lockAccessibleColors.RUnlock()
+	return calls
+}
+
+// AccessiblePrompter calls AccessiblePrompterFunc.
+func (mock *ConfigMock) AccessiblePrompter(hostname string) gh.ConfigEntry {
+	if mock.AccessiblePrompterFunc == nil {
+		panic("ConfigMock.AccessiblePrompterFunc: method is nil but Config.AccessiblePrompter was just called")
+	}
+	callInfo := struct {
+		Hostname string
+	}{
+		Hostname: hostname,
+	}
+	mock.lockAccessiblePrompter.Lock()
+	mock.calls.AccessiblePrompter = append(mock.calls.AccessiblePrompter, callInfo)
+	mock.lockAccessiblePrompter.Unlock()
+	return mock.AccessiblePrompterFunc(hostname)
+}
+
+// AccessiblePrompterCalls gets all the calls that were made to AccessiblePrompter.
+// Check the length with:
+//
+//	len(mockedConfig.AccessiblePrompterCalls())
+func (mock *ConfigMock) AccessiblePrompterCalls() []struct {
+	Hostname string
+} {
+	var calls []struct {
+		Hostname string
+	}
+	mock.lockAccessiblePrompter.RLock()
+	calls = mock.calls.AccessiblePrompter
+	mock.lockAccessiblePrompter.RUnlock()
+	return calls
+}
+
 // Aliases calls AliasesFunc.
 func (mock *ConfigMock) Aliases() gh.AliasConfig {
 	if mock.AliasesFunc == nil {
@@ -320,6 +432,38 @@ func (mock *ConfigMock) CacheDirCalls() []struct {
 	return calls
 }
 
+// ColorLabels calls ColorLabelsFunc.
+func (mock *ConfigMock) ColorLabels(hostname string) gh.ConfigEntry {
+	if mock.ColorLabelsFunc == nil {
+		panic("ConfigMock.ColorLabelsFunc: method is nil but Config.ColorLabels was just called")
+	}
+	callInfo := struct {
+		Hostname string
+	}{
+		Hostname: hostname,
+	}
+	mock.lockColorLabels.Lock()
+	mock.calls.ColorLabels = append(mock.calls.ColorLabels, callInfo)
+	mock.lockColorLabels.Unlock()
+	return mock.ColorLabelsFunc(hostname)
+}
+
+// ColorLabelsCalls gets all the calls that were made to ColorLabels.
+// Check the length with:
+//
+//	len(mockedConfig.ColorLabelsCalls())
+func (mock *ConfigMock) ColorLabelsCalls() []struct {
+	Hostname string
+} {
+	var calls []struct {
+		Hostname string
+	}
+	mock.lockColorLabels.RLock()
+	calls = mock.calls.ColorLabels
+	mock.lockColorLabels.RUnlock()
+	return calls
+}
+
 // Editor calls EditorFunc.
 func (mock *ConfigMock) Editor(hostname string) gh.ConfigEntry {
 	if mock.EditorFunc == nil {
@@ -620,6 +764,38 @@ func (mock *ConfigMock) SetCalls() []struct {
 	return calls
 }
 
+// Spinner calls SpinnerFunc.
+func (mock *ConfigMock) Spinner(hostname string) gh.ConfigEntry {
+	if mock.SpinnerFunc == nil {
+		panic("ConfigMock.SpinnerFunc: method is nil but Config.Spinner was just called")
+	}
+	callInfo := struct {
+		Hostname string
+	}{
+		Hostname: hostname,
+	}
+	mock.lockSpinner.Lock()
+	mock.calls.Spinner = append(mock.calls.Spinner, callInfo)
+	mock.lockSpinner.Unlock()
+	return mock.SpinnerFunc(hostname)
+}
+
+// SpinnerCalls gets all the calls that were made to Spinner.
+// Check the length with:
+//
+//	len(mockedConfig.SpinnerCalls())
+func (mock *ConfigMock) SpinnerCalls() []struct {
+	Hostname string
+} {
+	var calls []struct {
+		Hostname string
+	}
+	mock.lockSpinner.RLock()
+	calls = mock.calls.Spinner
+	mock.lockSpinner.RUnlock()
+	return calls
+}
+
 // Version calls VersionFunc.
 func (mock *ConfigMock) Version() o.Option[string] {
 	if mock.VersionFunc == nil {
diff --git a/internal/gh/projects.go b/internal/gh/projects.go
new file mode 100644
index 000000000..34acf8d7c
--- /dev/null
+++ b/internal/gh/projects.go
@@ -0,0 +1,23 @@
+package gh
+
+// ProjectsV1Support provides type safety and readability around whether or not Projects v1 is supported
+// by the targeted host.
+//
+// It is a sealed type to ensure that consumers must use the exported ProjectsV1Supported and ProjectsV1Unsupported
+// variables to get an instance of the type.
+type ProjectsV1Support interface {
+	sealed()
+}
+
+type projectsV1Supported struct{}
+
+func (projectsV1Supported) sealed() {}
+
+type projectsV1Unsupported struct{}
+
+func (projectsV1Unsupported) sealed() {}
+
+var (
+	ProjectsV1Supported   ProjectsV1Support = projectsV1Supported{}
+	ProjectsV1Unsupported ProjectsV1Support = projectsV1Unsupported{}
+)
diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go
index 1d4b11cbc..c3281efbf 100644
--- a/internal/prompter/prompter.go
+++ b/internal/prompter/prompter.go
@@ -5,7 +5,9 @@ import (
 	"strings"
 
 	"github.com/AlecAivazis/survey/v2"
+	"github.com/charmbracelet/huh"
 	"github.com/cli/cli/v2/internal/ghinstance"
+	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/cli/cli/v2/pkg/surveyext"
 	ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
 )
@@ -13,29 +15,258 @@ import (
 //go:generate moq -rm -out prompter_mock.go . Prompter
 type Prompter interface {
 	// generic prompts from go-gh
-	Select(string, string, []string) (int, error)
+
+	// Select prompts the user to select an option from a list of options.
+	Select(prompt string, defaultValue string, options []string) (int, error)
+	// MultiSelect prompts the user to select one or more options from a list of options.
 	MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
-	Input(string, string) (string, error)
-	Password(string) (string, error)
-	Confirm(string, bool) (bool, error)
+	// Input prompts the user to enter a string value.
+	Input(prompt string, defaultValue string) (string, error)
+	// Password prompts the user to enter a password.
+	Password(prompt string) (string, error)
+	// Confirm prompts the user to confirm an action.
+	Confirm(prompt string, defaultValue bool) (bool, error)
 
 	// gh specific prompts
+
+	// AuthToken prompts the user to enter an authentication token.
 	AuthToken() (string, error)
-	ConfirmDeletion(string) error
+	// ConfirmDeletion prompts the user to confirm deletion of a resource by
+	// typing the requiredValue.
+	ConfirmDeletion(requiredValue string) error
+	// InputHostname prompts the user to enter a hostname.
 	InputHostname() (string, error)
-	MarkdownEditor(string, string, bool) (string, error)
+	// MarkdownEditor prompts the user to edit a markdown document in an editor.
+	// If blankAllowed is true, the user can skip the editor and an empty string
+	// will be returned.
+	MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
 }
 
-func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter {
+func New(editorCmd string, io *iostreams.IOStreams) Prompter {
+	if io.AccessiblePrompterEnabled() {
+		return &accessiblePrompter{
+			stdin:     io.In,
+			stdout:    io.Out,
+			stderr:    io.ErrOut,
+			editorCmd: editorCmd,
+		}
+	}
+
 	return &surveyPrompter{
-		prompter:  ghPrompter.New(stdin, stdout, stderr),
-		stdin:     stdin,
-		stdout:    stdout,
-		stderr:    stderr,
+		prompter:  ghPrompter.New(io.In, io.Out, io.ErrOut),
+		stdin:     io.In,
+		stdout:    io.Out,
+		stderr:    io.ErrOut,
 		editorCmd: editorCmd,
 	}
 }
 
+type accessiblePrompter struct {
+	stdin     ghPrompter.FileReader
+	stdout    ghPrompter.FileWriter
+	stderr    ghPrompter.FileWriter
+	editorCmd string
+}
+
+func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
+	return huh.NewForm(groups...).
+		WithTheme(huh.ThemeBase16()).
+		WithAccessible(true).
+		WithInput(p.stdin).
+		WithOutput(p.stdout)
+}
+
+func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) {
+	var result int
+	formOptions := []huh.Option[int]{}
+	for i, o := range options {
+		formOptions = append(formOptions, huh.NewOption(o, i))
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewSelect[int]().
+				Title(prompt).
+				Value(&result).
+				Options(formOptions...),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
+	var result []int
+	formOptions := make([]huh.Option[int], len(options))
+	for i, o := range options {
+		formOptions[i] = huh.NewOption(o, i)
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewMultiSelect[int]().
+				Title(prompt).
+				Value(&result).
+				Limit(len(options)).
+				Options(formOptions...),
+		),
+	)
+
+	if err := form.Run(); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) {
+	result := defaultValue
+	prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue)
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title(prompt).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) Password(prompt string) (string, error) {
+	var result string
+	// EchoModePassword is not used as password masking is unsupported in huh.
+	// EchoModeNone and EchoModePassword have the same effect of hiding user input.
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				EchoMode(huh.EchoModeNone).
+				Title(prompt).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	if err != nil {
+		return "", err
+	}
+
+	return result, nil
+}
+
+func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
+	result := defaultValue
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewConfirm().
+				Title(prompt).
+				Value(&result),
+		),
+	)
+	if err := form.Run(); err != nil {
+		return false, err
+	}
+	return result, nil
+}
+
+func (p *accessiblePrompter) AuthToken() (string, error) {
+	var result string
+	// EchoModeNone and EchoModePassword both result in disabling echo mode
+	// as password masking is outside of VT100 spec.
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				EchoMode(huh.EchoModeNone).
+				Title("Paste your authentication token:").
+				// Note: if this validation fails, the prompt loops.
+				Validate(func(input string) error {
+					if input == "" {
+						return fmt.Errorf("token is required")
+					}
+					return nil
+				}).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	return result, err
+}
+
+func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error {
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)).
+				Validate(func(input string) error {
+					if input != requiredValue {
+						return fmt.Errorf("You entered: %q", input)
+					}
+					return nil
+				}),
+		),
+	)
+
+	return form.Run()
+}
+
+func (p *accessiblePrompter) InputHostname() (string, error) {
+	var result string
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Hostname:").
+				Validate(ghinstance.HostnameValidator).
+				Value(&result),
+		),
+	)
+
+	err := form.Run()
+	if err != nil {
+		return "", err
+	}
+	return result, nil
+}
+
+func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
+	var result string
+	skipOption := "skip"
+	launchOption := "launch"
+	options := []huh.Option[string]{
+		huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption),
+	}
+	if blankAllowed {
+		options = append(options, huh.NewOption("Skip", skipOption))
+	}
+
+	form := p.newForm(
+		huh.NewGroup(
+			huh.NewSelect[string]().
+				Title(prompt).
+				Options(options...).
+				Value(&result),
+		),
+	)
+
+	if err := form.Run(); err != nil {
+		return "", err
+	}
+
+	if result == skipOption {
+		return "", nil
+	}
+
+	// launchOption was selected
+	text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr)
+	if err != nil {
+		return "", err
+	}
+
+	return text, nil
+}
+
 type surveyPrompter struct {
 	prompter  *ghPrompter.Prompter
 	stdin     ghPrompter.FileReader
diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go
index b817a491f..b15f8bf96 100644
--- a/internal/prompter/prompter_mock.go
+++ b/internal/prompter/prompter_mock.go
@@ -20,28 +20,28 @@ var _ Prompter = &PrompterMock{}
 //			AuthTokenFunc: func() (string, error) {
 //				panic("mock out the AuthToken method")
 //			},
-//			ConfirmFunc: func(s string, b bool) (bool, error) {
+//			ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) {
 //				panic("mock out the Confirm method")
 //			},
-//			ConfirmDeletionFunc: func(s string) error {
+//			ConfirmDeletionFunc: func(requiredValue string) error {
 //				panic("mock out the ConfirmDeletion method")
 //			},
-//			InputFunc: func(s1 string, s2 string) (string, error) {
+//			InputFunc: func(prompt string, defaultValue string) (string, error) {
 //				panic("mock out the Input method")
 //			},
 //			InputHostnameFunc: func() (string, error) {
 //				panic("mock out the InputHostname method")
 //			},
-//			MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
+//			MarkdownEditorFunc: func(prompt string, defaultValue string, blankAllowed bool) (string, error) {
 //				panic("mock out the MarkdownEditor method")
 //			},
 //			MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
 //				panic("mock out the MultiSelect method")
 //			},
-//			PasswordFunc: func(s string) (string, error) {
+//			PasswordFunc: func(prompt string) (string, error) {
 //				panic("mock out the Password method")
 //			},
-//			SelectFunc: func(s1 string, s2 string, strings []string) (int, error) {
+//			SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
 //				panic("mock out the Select method")
 //			},
 //		}
@@ -55,28 +55,28 @@ type PrompterMock struct {
 	AuthTokenFunc func() (string, error)
 
 	// ConfirmFunc mocks the Confirm method.
-	ConfirmFunc func(s string, b bool) (bool, error)
+	ConfirmFunc func(prompt string, defaultValue bool) (bool, error)
 
 	// ConfirmDeletionFunc mocks the ConfirmDeletion method.
-	ConfirmDeletionFunc func(s string) error
+	ConfirmDeletionFunc func(requiredValue string) error
 
 	// InputFunc mocks the Input method.
-	InputFunc func(s1 string, s2 string) (string, error)
+	InputFunc func(prompt string, defaultValue string) (string, error)
 
 	// InputHostnameFunc mocks the InputHostname method.
 	InputHostnameFunc func() (string, error)
 
 	// MarkdownEditorFunc mocks the MarkdownEditor method.
-	MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error)
+	MarkdownEditorFunc func(prompt string, defaultValue string, blankAllowed bool) (string, error)
 
 	// MultiSelectFunc mocks the MultiSelect method.
 	MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error)
 
 	// PasswordFunc mocks the Password method.
-	PasswordFunc func(s string) (string, error)
+	PasswordFunc func(prompt string) (string, error)
 
 	// SelectFunc mocks the Select method.
-	SelectFunc func(s1 string, s2 string, strings []string) (int, error)
+	SelectFunc func(prompt string, defaultValue string, options []string) (int, error)
 
 	// calls tracks calls to the methods.
 	calls struct {
@@ -85,34 +85,34 @@ type PrompterMock struct {
 		}
 		// Confirm holds details about calls to the Confirm method.
 		Confirm []struct {
-			// S is the s argument value.
-			S string
-			// B is the b argument value.
-			B bool
+			// Prompt is the prompt argument value.
+			Prompt string
+			// DefaultValue is the defaultValue argument value.
+			DefaultValue bool
 		}
 		// ConfirmDeletion holds details about calls to the ConfirmDeletion method.
 		ConfirmDeletion []struct {
-			// S is the s argument value.
-			S string
+			// RequiredValue is the requiredValue argument value.
+			RequiredValue string
 		}
 		// Input holds details about calls to the Input method.
 		Input []struct {
-			// S1 is the s1 argument value.
-			S1 string
-			// S2 is the s2 argument value.
-			S2 string
+			// Prompt is the prompt argument value.
+			Prompt string
+			// DefaultValue is the defaultValue argument value.
+			DefaultValue string
 		}
 		// InputHostname holds details about calls to the InputHostname method.
 		InputHostname []struct {
 		}
 		// MarkdownEditor holds details about calls to the MarkdownEditor method.
 		MarkdownEditor []struct {
-			// S1 is the s1 argument value.
-			S1 string
-			// S2 is the s2 argument value.
-			S2 string
-			// B is the b argument value.
-			B bool
+			// Prompt is the prompt argument value.
+			Prompt string
+			// DefaultValue is the defaultValue argument value.
+			DefaultValue string
+			// BlankAllowed is the blankAllowed argument value.
+			BlankAllowed bool
 		}
 		// MultiSelect holds details about calls to the MultiSelect method.
 		MultiSelect []struct {
@@ -125,17 +125,17 @@ type PrompterMock struct {
 		}
 		// Password holds details about calls to the Password method.
 		Password []struct {
-			// S is the s argument value.
-			S string
+			// Prompt is the prompt argument value.
+			Prompt string
 		}
 		// Select holds details about calls to the Select method.
 		Select []struct {
-			// S1 is the s1 argument value.
-			S1 string
-			// S2 is the s2 argument value.
-			S2 string
-			// Strings is the strings argument value.
-			Strings []string
+			// Prompt is the prompt argument value.
+			Prompt string
+			// DefaultValue is the defaultValue argument value.
+			DefaultValue string
+			// Options is the options argument value.
+			Options []string
 		}
 	}
 	lockAuthToken       sync.RWMutex
@@ -177,21 +177,21 @@ func (mock *PrompterMock) AuthTokenCalls() []struct {
 }
 
 // Confirm calls ConfirmFunc.
-func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) {
+func (mock *PrompterMock) Confirm(prompt string, defaultValue bool) (bool, error) {
 	if mock.ConfirmFunc == nil {
 		panic("PrompterMock.ConfirmFunc: method is nil but Prompter.Confirm was just called")
 	}
 	callInfo := struct {
-		S string
-		B bool
+		Prompt       string
+		DefaultValue bool
 	}{
-		S: s,
-		B: b,
+		Prompt:       prompt,
+		DefaultValue: defaultValue,
 	}
 	mock.lockConfirm.Lock()
 	mock.calls.Confirm = append(mock.calls.Confirm, callInfo)
 	mock.lockConfirm.Unlock()
-	return mock.ConfirmFunc(s, b)
+	return mock.ConfirmFunc(prompt, defaultValue)
 }
 
 // ConfirmCalls gets all the calls that were made to Confirm.
@@ -199,12 +199,12 @@ func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) {
 //
 //	len(mockedPrompter.ConfirmCalls())
 func (mock *PrompterMock) ConfirmCalls() []struct {
-	S string
-	B bool
+	Prompt       string
+	DefaultValue bool
 } {
 	var calls []struct {
-		S string
-		B bool
+		Prompt       string
+		DefaultValue bool
 	}
 	mock.lockConfirm.RLock()
 	calls = mock.calls.Confirm
@@ -213,19 +213,19 @@ func (mock *PrompterMock) ConfirmCalls() []struct {
 }
 
 // ConfirmDeletion calls ConfirmDeletionFunc.
-func (mock *PrompterMock) ConfirmDeletion(s string) error {
+func (mock *PrompterMock) ConfirmDeletion(requiredValue string) error {
 	if mock.ConfirmDeletionFunc == nil {
 		panic("PrompterMock.ConfirmDeletionFunc: method is nil but Prompter.ConfirmDeletion was just called")
 	}
 	callInfo := struct {
-		S string
+		RequiredValue string
 	}{
-		S: s,
+		RequiredValue: requiredValue,
 	}
 	mock.lockConfirmDeletion.Lock()
 	mock.calls.ConfirmDeletion = append(mock.calls.ConfirmDeletion, callInfo)
 	mock.lockConfirmDeletion.Unlock()
-	return mock.ConfirmDeletionFunc(s)
+	return mock.ConfirmDeletionFunc(requiredValue)
 }
 
 // ConfirmDeletionCalls gets all the calls that were made to ConfirmDeletion.
@@ -233,10 +233,10 @@ func (mock *PrompterMock) ConfirmDeletion(s string) error {
 //
 //	len(mockedPrompter.ConfirmDeletionCalls())
 func (mock *PrompterMock) ConfirmDeletionCalls() []struct {
-	S string
+	RequiredValue string
 } {
 	var calls []struct {
-		S string
+		RequiredValue string
 	}
 	mock.lockConfirmDeletion.RLock()
 	calls = mock.calls.ConfirmDeletion
@@ -245,21 +245,21 @@ func (mock *PrompterMock) ConfirmDeletionCalls() []struct {
 }
 
 // Input calls InputFunc.
-func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) {
+func (mock *PrompterMock) Input(prompt string, defaultValue string) (string, error) {
 	if mock.InputFunc == nil {
 		panic("PrompterMock.InputFunc: method is nil but Prompter.Input was just called")
 	}
 	callInfo := struct {
-		S1 string
-		S2 string
+		Prompt       string
+		DefaultValue string
 	}{
-		S1: s1,
-		S2: s2,
+		Prompt:       prompt,
+		DefaultValue: defaultValue,
 	}
 	mock.lockInput.Lock()
 	mock.calls.Input = append(mock.calls.Input, callInfo)
 	mock.lockInput.Unlock()
-	return mock.InputFunc(s1, s2)
+	return mock.InputFunc(prompt, defaultValue)
 }
 
 // InputCalls gets all the calls that were made to Input.
@@ -267,12 +267,12 @@ func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) {
 //
 //	len(mockedPrompter.InputCalls())
 func (mock *PrompterMock) InputCalls() []struct {
-	S1 string
-	S2 string
+	Prompt       string
+	DefaultValue string
 } {
 	var calls []struct {
-		S1 string
-		S2 string
+		Prompt       string
+		DefaultValue string
 	}
 	mock.lockInput.RLock()
 	calls = mock.calls.Input
@@ -308,23 +308,23 @@ func (mock *PrompterMock) InputHostnameCalls() []struct {
 }
 
 // MarkdownEditor calls MarkdownEditorFunc.
-func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string, error) {
+func (mock *PrompterMock) MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) {
 	if mock.MarkdownEditorFunc == nil {
 		panic("PrompterMock.MarkdownEditorFunc: method is nil but Prompter.MarkdownEditor was just called")
 	}
 	callInfo := struct {
-		S1 string
-		S2 string
-		B  bool
+		Prompt       string
+		DefaultValue string
+		BlankAllowed bool
 	}{
-		S1: s1,
-		S2: s2,
-		B:  b,
+		Prompt:       prompt,
+		DefaultValue: defaultValue,
+		BlankAllowed: blankAllowed,
 	}
 	mock.lockMarkdownEditor.Lock()
 	mock.calls.MarkdownEditor = append(mock.calls.MarkdownEditor, callInfo)
 	mock.lockMarkdownEditor.Unlock()
-	return mock.MarkdownEditorFunc(s1, s2, b)
+	return mock.MarkdownEditorFunc(prompt, defaultValue, blankAllowed)
 }
 
 // MarkdownEditorCalls gets all the calls that were made to MarkdownEditor.
@@ -332,14 +332,14 @@ func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string,
 //
 //	len(mockedPrompter.MarkdownEditorCalls())
 func (mock *PrompterMock) MarkdownEditorCalls() []struct {
-	S1 string
-	S2 string
-	B  bool
+	Prompt       string
+	DefaultValue string
+	BlankAllowed bool
 } {
 	var calls []struct {
-		S1 string
-		S2 string
-		B  bool
+		Prompt       string
+		DefaultValue string
+		BlankAllowed bool
 	}
 	mock.lockMarkdownEditor.RLock()
 	calls = mock.calls.MarkdownEditor
@@ -388,19 +388,19 @@ func (mock *PrompterMock) MultiSelectCalls() []struct {
 }
 
 // Password calls PasswordFunc.
-func (mock *PrompterMock) Password(s string) (string, error) {
+func (mock *PrompterMock) Password(prompt string) (string, error) {
 	if mock.PasswordFunc == nil {
 		panic("PrompterMock.PasswordFunc: method is nil but Prompter.Password was just called")
 	}
 	callInfo := struct {
-		S string
+		Prompt string
 	}{
-		S: s,
+		Prompt: prompt,
 	}
 	mock.lockPassword.Lock()
 	mock.calls.Password = append(mock.calls.Password, callInfo)
 	mock.lockPassword.Unlock()
-	return mock.PasswordFunc(s)
+	return mock.PasswordFunc(prompt)
 }
 
 // PasswordCalls gets all the calls that were made to Password.
@@ -408,10 +408,10 @@ func (mock *PrompterMock) Password(s string) (string, error) {
 //
 //	len(mockedPrompter.PasswordCalls())
 func (mock *PrompterMock) PasswordCalls() []struct {
-	S string
+	Prompt string
 } {
 	var calls []struct {
-		S string
+		Prompt string
 	}
 	mock.lockPassword.RLock()
 	calls = mock.calls.Password
@@ -420,23 +420,23 @@ func (mock *PrompterMock) PasswordCalls() []struct {
 }
 
 // Select calls SelectFunc.
-func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, error) {
+func (mock *PrompterMock) Select(prompt string, defaultValue string, options []string) (int, error) {
 	if mock.SelectFunc == nil {
 		panic("PrompterMock.SelectFunc: method is nil but Prompter.Select was just called")
 	}
 	callInfo := struct {
-		S1      string
-		S2      string
-		Strings []string
+		Prompt       string
+		DefaultValue string
+		Options      []string
 	}{
-		S1:      s1,
-		S2:      s2,
-		Strings: strings,
+		Prompt:       prompt,
+		DefaultValue: defaultValue,
+		Options:      options,
 	}
 	mock.lockSelect.Lock()
 	mock.calls.Select = append(mock.calls.Select, callInfo)
 	mock.lockSelect.Unlock()
-	return mock.SelectFunc(s1, s2, strings)
+	return mock.SelectFunc(prompt, defaultValue, options)
 }
 
 // SelectCalls gets all the calls that were made to Select.
@@ -444,14 +444,14 @@ func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, e
 //
 //	len(mockedPrompter.SelectCalls())
 func (mock *PrompterMock) SelectCalls() []struct {
-	S1      string
-	S2      string
-	Strings []string
+	Prompt       string
+	DefaultValue string
+	Options      []string
 } {
 	var calls []struct {
-		S1      string
-		S2      string
-		Strings []string
+		Prompt       string
+		DefaultValue string
+		Options      []string
 	}
 	mock.lockSelect.RLock()
 	calls = mock.calls.Select
diff --git a/internal/run/stub.go b/internal/run/stub.go
index 5cd3c6de5..507fd61d6 100644
--- a/internal/run/stub.go
+++ b/internal/run/stub.go
@@ -46,7 +46,7 @@ func Stub() (*CommandStubber, func(T)) {
 			return
 		}
 		t.Helper()
-		t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
+		t.Errorf("unmatched exec stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
 	}
 }
 
diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go
index e1454170f..47128afb4 100644
--- a/internal/tableprinter/table_printer.go
+++ b/internal/tableprinter/table_printer.go
@@ -73,14 +73,14 @@ func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorSch
 		// was not padded. In tests cs.Enabled() is false which allows us to avoid having to fix up
 		// numerous tests that verify header padding.
 		var paddingFunc func(int, string) string
-		if cs.Enabled() {
+		if cs.Enabled {
 			paddingFunc = text.PadRight
 		}
 
 		tp.AddHeader(
 			upperCasedHeaders,
 			WithPadding(paddingFunc),
-			WithColor(cs.LightGrayUnderline),
+			WithColor(cs.TableHeader),
 		)
 	}
 
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index d25b4ce64..586aeae93 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"io"
 	"net/http"
-	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -265,8 +264,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
 				return err
 			}
 
-			opts.RequestPath = escapePackageNameInPath(opts.RequestPath)
-
 			if runF != nil {
 				return runF(&opts)
 			}
@@ -694,37 +691,3 @@ func previewNamesToMIMETypes(names []string) string {
 	}
 	return strings.Join(types, ", ")
 }
-
-// The package name part in the `packages` endpoints may contain slashes and
-// other characters that need to be URL encoded.
-//
-// The `escapePackageNameInPath` function extracts and normalizes package names
-// in the path. The regex `pathWithPackageNameRE` is being used to extract the
-// package name with a capture group named `package`.
-//
-// See https://docs.github.com/en/rest/packages/packages APIs for more details.
-//
-// Here's an example:
-//
-// The package name `orders/cache` needs to be URL encoded because it contains
-// a slash `/`. The `escapePackageNameInPath` function will extract the
-// `orders/cache` part, perform the URL encoding, and return the normalized API
-// endpoint with `%2F` replacing the slash `/` in the package name part only.
-//
-// - Package name: `orders/cache`
-// - API endpoint: `/users/USER/packages/container/orders/cache`
-// - Normalized:   `/users/USER/packages/container/orders%2Fcache`
-
-var pathWithPackageNameRE = regexp.MustCompile(`^\/(?:orgs|user|users)(?:\/.*)?\/packages\/(?:npm|maven|rubygems|docker|nuget|container)\/(?<package>.*?)(?:\/(?:restore|versions)|$)`)
-
-func escapePackageNameInPath(path string) string {
-	matches := pathWithPackageNameRE.FindStringSubmatch(path)
-	if len(matches) > 0 {
-		i := pathWithPackageNameRE.SubexpIndex("package")
-		packageName := matches[i]
-		if packageName != "" {
-			return strings.Replace(path, packageName, url.QueryEscape(packageName), 1)
-		}
-	}
-	return path
-}
diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go
index 9f39876cb..d699e2c3d 100644
--- a/pkg/cmd/cache/list/list.go
+++ b/pkg/cmd/cache/list/list.go
@@ -99,11 +99,12 @@ func listRun(opts *ListOptions) error {
 	}
 	client := api.NewClientFromHTTP(httpClient)
 
+	cs := opts.IO.ColorScheme()
 	opts.IO.StartProgressIndicator()
 	result, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: opts.Limit, Sort: opts.Sort, Order: opts.Order, Key: opts.Key, Ref: opts.Ref})
 	opts.IO.StopProgressIndicator()
 	if err != nil {
-		return fmt.Errorf("%s Failed to get caches: %w", opts.IO.ColorScheme().FailureIcon(), err)
+		return fmt.Errorf("%s Failed to get caches: %w", cs.FailureIcon(), err)
 	}
 
 	if len(result.ActionsCaches) == 0 && opts.Exporter == nil {
@@ -130,11 +131,11 @@ func listRun(opts *ListOptions) error {
 
 	tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "KEY", "SIZE", "CREATED", "ACCESSED"))
 	for _, cache := range result.ActionsCaches {
-		tp.AddField(opts.IO.ColorScheme().Cyan(fmt.Sprintf("%d", cache.Id)))
+		tp.AddField(cs.Cyanf("%d", cache.Id))
 		tp.AddField(cache.Key)
 		tp.AddField(humanFileSize(cache.SizeInBytes))
-		tp.AddTimeField(opts.Now, cache.CreatedAt, opts.IO.ColorScheme().Gray)
-		tp.AddTimeField(opts.Now, cache.LastAccessedAt, opts.IO.ColorScheme().Gray)
+		tp.AddTimeField(opts.Now, cache.CreatedAt, cs.Muted)
+		tp.AddTimeField(opts.Now, cache.LastAccessedAt, cs.Muted)
 		tp.EndRow()
 	}
 
diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go
index c1acccabb..238c4a6a7 100644
--- a/pkg/cmd/codespace/list.go
+++ b/pkg/cmd/codespace/list.go
@@ -152,7 +152,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
 		case false:
 			nameColor = cs.Yellow
 		case true:
-			nameColor = cs.Gray
+			nameColor = cs.Muted
 		}
 
 		tp.AddField(formattedName, tableprinter.WithColor(nameColor))
@@ -172,7 +172,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
 		if err != nil {
 			return fmt.Errorf("error parsing date %q: %w", c.CreatedAt, err)
 		}
-		tp.AddTimeField(time.Now(), ct, cs.Gray)
+		tp.AddTimeField(time.Now(), ct, cs.Muted)
 
 		if hasNonProdVSCSTarget {
 			tp.AddField(c.VSCSTarget)
diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go
index dc7ffa8c3..f30bf63c1 100644
--- a/pkg/cmd/extension/extension.go
+++ b/pkg/cmd/extension/extension.go
@@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool {
 			isPinned = manifest.IsPinned
 		}
 	case GitKind:
-		pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion()))
+		extDir := filepath.Dir(e.path)
+		pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion()))
 		if _, err := os.Stat(pinPath); err == nil {
 			isPinned = true
 		} else {
diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go
index 923651487..52837b252 100644
--- a/pkg/cmd/factory/default.go
+++ b/pkg/cmd/factory/default.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"os"
 	"regexp"
+	"slices"
 	"time"
 
 	"github.com/cli/cli/v2/api"
@@ -19,6 +20,7 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/extension"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
+	xcolor "github.com/cli/go-gh/v2/pkg/x/color"
 )
 
 var ssoHeader string
@@ -225,7 +227,7 @@ func newBrowser(f *cmdutil.Factory) browser.Browser {
 func newPrompter(f *cmdutil.Factory) prompter.Prompter {
 	editor, _ := cmdutil.DetermineEditor(f.Config)
 	io := f.IOStreams
-	return prompter.New(editor, io.In, io.Out, io.ErrOut)
+	return prompter.New(editor, io)
 }
 
 func configFunc() func() (gh.Config, error) {
@@ -282,6 +284,26 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
 		io.SetNeverPrompt(true)
 	}
 
+	falseyValues := []string{"false", "0", "no", ""}
+
+	accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
+	if accessiblePrompterIsSet {
+		if !slices.Contains(falseyValues, accessiblePrompterValue) {
+			io.SetAccessiblePrompterEnabled(true)
+		}
+	} else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" {
+		io.SetAccessiblePrompterEnabled(true)
+	}
+
+	ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
+	if ghSpinnerDisabledIsSet {
+		if !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
+			io.SetSpinnerDisabled(true)
+		}
+	} else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" {
+		io.SetSpinnerDisabled(true)
+	}
+
 	// Pager precedence
 	// 1. GH_PAGER
 	// 2. pager from config
@@ -292,6 +314,19 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
 		io.SetPager(pager.Value)
 	}
 
+	if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists {
+		switch ghColorLabels {
+		case "", "0", "false", "no":
+			io.SetColorLabels(false)
+		default:
+			io.SetColorLabels(true)
+		}
+	} else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" {
+		io.SetColorLabels(true)
+	}
+
+	io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled())
+
 	return io
 }
 
diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go
index e9d9f2c14..4f51bed25 100644
--- a/pkg/cmd/gist/create/create.go
+++ b/pkg/cmd/gist/create/create.go
@@ -48,13 +48,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 	}
 
 	cmd := &cobra.Command{
-		Use:   "create [<filename>... | -]",
+		Use:   "create [<filename>... | <pattern>... | -]",
 		Short: "Create a new gist",
 		Long: heredoc.Docf(`
 			Create a new GitHub gist with given contents.
 
 			Gists can be created from one or multiple files. Alternatively, pass %[1]s-%[1]s as
-			file name to read from standard input.
+			filename to read from standard input.
 
 			By default, gists are secret; use %[1]s--public%[1]s to make publicly listed ones.
 		`, "`"),
@@ -68,6 +68,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 			# Create a gist containing several files
 			$ gh gist create hello.py world.py cool.txt
 
+			# Create a gist containing several files using patterns
+			$ gh gist create *.md *.txt artifact.*
+
 			# Read from standard input to create a gist
 			$ gh gist create -
 
@@ -102,12 +105,23 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 }
 
 func createRun(opts *CreateOptions) error {
-	fileArgs := opts.Filenames
-	if len(fileArgs) == 0 {
-		fileArgs = []string{"-"}
+
+	readFromStdInArg, filenames := cmdutil.Partition(opts.Filenames, func(f string) bool {
+		return f == "-"
+	})
+
+	filenames, err := cmdutil.GlobPaths(filenames)
+	if err != nil {
+		return err
+	}
+
+	filenames = append(filenames, readFromStdInArg...)
+
+	if len(filenames) == 0 {
+		filenames = []string{"-"}
 	}
 
-	files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs)
+	files, err := processFiles(opts.IO.In, opts.FilenameOverride, filenames)
 	if err != nil {
 		return fmt.Errorf("failed to collect files for posting: %w", err)
 	}
@@ -124,7 +138,7 @@ func createRun(opts *CreateOptions) error {
 			processMessage = fmt.Sprintf("Creating gist %s", gistName)
 		}
 	}
-	fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage)
+	fmt.Fprintf(errOut, "%s %s\n", cs.Muted("-"), processMessage)
 
 	httpClient, err := opts.HttpClient()
 	if err != nil {
diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go
index d46d4da02..a4fd245f6 100644
--- a/pkg/cmd/gist/list/list.go
+++ b/pkg/cmd/gist/list/list.go
@@ -206,7 +206,7 @@ func printTable(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Reg
 			tableprinter.WithColor(highlightFilesFunc(&gist)),
 		)
 		tp.AddField(visibility, tableprinter.WithColor(visColor))
-		tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Gray)
+		tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Muted)
 		tp.EndRow()
 	}
 
diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go
index 53b577e4c..fc63f56ce 100644
--- a/pkg/cmd/gist/shared/shared.go
+++ b/pkg/cmd/gist/shared/shared.go
@@ -230,7 +230,7 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c
 	for i, gist := range gists {
 		gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt)
 		// TODO: support dynamic maxWidth
-		opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Gray(gistTime))
+		opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Muted(gistTime))
 	}
 
 	result, err := prompter.Select("Select a gist", "", opts)
diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go
index 705f8f703..f789c5b04 100644
--- a/pkg/cmd/gist/view/view.go
+++ b/pkg/cmd/gist/view/view.go
@@ -140,7 +140,7 @@ func viewRun(opts *ViewOptions) error {
 			if len(gist.Files) == 1 || opts.Filename != "" {
 				return fmt.Errorf("error: file is binary")
 			}
-			_, err = fmt.Fprintln(opts.IO.Out, cs.Gray("(skipping rendering binary content)"))
+			_, err = fmt.Fprintln(opts.IO.Out, cs.Muted("(skipping rendering binary content)"))
 			return nil
 		}
 
@@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error {
 
 	for i, fn := range filenames {
 		if showFilenames {
-			fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray(fn))
+			fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Muted(fn))
 		}
 		if err := render(gist.Files[fn]); err != nil {
 			return err
diff --git a/pkg/cmd/gpg-key/list/list.go b/pkg/cmd/gpg-key/list/list.go
index 9acf1d7b6..4244ba349 100644
--- a/pkg/cmd/gpg-key/list/list.go
+++ b/pkg/cmd/gpg-key/list/list.go
@@ -78,7 +78,7 @@ func listRun(opts *ListOptions) error {
 		t.AddField(gpgKey.Emails.String())
 		t.AddField(gpgKey.KeyID)
 		t.AddField(gpgKey.PublicKey, tableprinter.WithTruncate(truncateMiddle))
-		t.AddTimeField(now, gpgKey.CreatedAt, cs.Gray)
+		t.AddTimeField(now, gpgKey.CreatedAt, cs.Muted)
 		expiresAt := gpgKey.ExpiresAt.Format(time.RFC3339)
 		if t.IsTTY() {
 			if gpgKey.ExpiresAt.IsZero() {
@@ -87,7 +87,7 @@ func listRun(opts *ListOptions) error {
 				expiresAt = gpgKey.ExpiresAt.Format("2006-01-02")
 			}
 		}
-		t.AddField(expiresAt, tableprinter.WithColor(cs.Gray))
+		t.AddField(expiresAt, tableprinter.WithColor(cs.Muted))
 		t.EndRow()
 	}
 
diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go
index 9197abff6..21fe45dd6 100644
--- a/pkg/cmd/issue/close/close.go
+++ b/pkg/cmd/issue/close/close.go
@@ -21,7 +21,7 @@ type CloseOptions struct {
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
 
-	SelectorArg string
+	IssueNumber int
 	Comment     string
 	Reason      string
 
@@ -39,13 +39,23 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
 		Short: "Close issue",
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
 
-			if len(args) > 0 {
-				opts.SelectorArg = args[0]
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
 			}
 
+			opts.IssueNumber = issueNumber
+
 			if runF != nil {
 				return runF(opts)
 			}
@@ -67,7 +77,12 @@ func closeRun(opts *CloseOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go
index 090b0748c..706ff791e 100644
--- a/pkg/cmd/issue/comment/comment.go
+++ b/pkg/cmd/issue/comment/comment.go
@@ -3,6 +3,7 @@ package comment
 import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -37,15 +38,41 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
 			opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) {
+				// TODO wm: more testing
+				issueNumber, parsedBaseRepo, err := shared.ParseIssueFromArg(args[0])
+				if err != nil {
+					return nil, nil, err
+				}
+
+				// If the args provided the base repo then use that directly.
+				var baseRepo ghrepo.Interface
+
+				if parsedBaseRepo, present := parsedBaseRepo.Value(); present {
+					baseRepo = parsedBaseRepo
+				} else {
+					// support `-R, --repo` override
+					baseRepo, err = f.BaseRepo()
+					if err != nil {
+						return nil, nil, err
+					}
+				}
+
 				httpClient, err := f.HttpClient()
 				if err != nil {
 					return nil, nil, err
 				}
+
 				fields := []string{"id", "url"}
 				if opts.EditLast {
 					fields = append(fields, "comments")
 				}
-				return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields)
+
+				issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, issueNumber, fields)
+				if err != nil {
+					return nil, nil, err
+				}
+
+				return issue, baseRepo, nil
 			}
 			return prShared.CommentablePreRun(cmd, opts)
 		},
diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go
index 2e3e0de51..2978a21fc 100644
--- a/pkg/cmd/issue/create/create.go
+++ b/pkg/cmd/issue/create/create.go
@@ -4,10 +4,12 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/browser"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/text"
@@ -24,6 +26,7 @@ type CreateOptions struct {
 	BaseRepo         func() (ghrepo.Interface, error)
 	Browser          browser.Browser
 	Prompter         prShared.Prompt
+	Detector         fd.Detector
 	TitledEditSurvey func(string, string) (string, string, error)
 
 	RootDirOverride string
@@ -46,11 +49,12 @@ type CreateOptions struct {
 
 func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
 	opts := &CreateOptions{
-		IO:               f.IOStreams,
-		HttpClient:       f.HttpClient,
-		Config:           f.Config,
-		Browser:          f.Browser,
-		Prompter:         f.Prompter,
+		IO:         f.IOStreams,
+		HttpClient: f.HttpClient,
+		Config:     f.Config,
+		Browser:    f.Browser,
+		Prompter:   f.Prompter,
+
 		TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
 	}
 
@@ -146,6 +150,15 @@ func createRun(opts *CreateOptions) (err error) {
 		return
 	}
 
+	// TODO projectsV1Deprecation
+	// Remove this section as we should no longer need to detect
+	if opts.Detector == nil {
+		cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
+		opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
+	}
+
+	projectsV1Support := opts.Detector.ProjectsV1()
+
 	isTerminal := opts.IO.IsStdoutTTY()
 
 	var milestones []string
@@ -160,13 +173,13 @@ func createRun(opts *CreateOptions) (err error) {
 	}
 
 	tb := prShared.IssueMetadataState{
-		Type:       prShared.IssueMetadata,
-		Assignees:  assignees,
-		Labels:     opts.Labels,
-		Projects:   opts.Projects,
-		Milestones: milestones,
-		Title:      opts.Title,
-		Body:       opts.Body,
+		Type:          prShared.IssueMetadata,
+		Assignees:     assignees,
+		Labels:        opts.Labels,
+		ProjectTitles: opts.Projects,
+		Milestones:    milestones,
+		Title:         opts.Title,
+		Body:          opts.Body,
 	}
 
 	if opts.RecoverFile != "" {
@@ -182,7 +195,7 @@ func createRun(opts *CreateOptions) (err error) {
 	if opts.WebMode {
 		var openURL string
 		if opts.Title != "" || opts.Body != "" || tb.HasMetadata() {
-			openURL, err = generatePreviewURL(apiClient, baseRepo, tb)
+			openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support)
 			if err != nil {
 				return
 			}
@@ -260,7 +273,7 @@ func createRun(opts *CreateOptions) (err error) {
 			}
 		}
 
-		openURL, err = generatePreviewURL(apiClient, baseRepo, tb)
+		openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support)
 		if err != nil {
 			return
 		}
@@ -279,7 +292,7 @@ func createRun(opts *CreateOptions) (err error) {
 				Repo:      baseRepo,
 				State:     &tb,
 			}
-			err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb)
+			err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support)
 			if err != nil {
 				return
 			}
@@ -335,7 +348,7 @@ func createRun(opts *CreateOptions) (err error) {
 			params["issueTemplate"] = templateNameForSubmit
 		}
 
-		err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
+		err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, projectsV1Support)
 		if err != nil {
 			return
 		}
@@ -354,7 +367,7 @@ func createRun(opts *CreateOptions) (err error) {
 	return
 }
 
-func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState) (string, error) {
+func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) {
 	openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
-	return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb)
+	return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support)
 }
diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go
index fb41f288e..269ef7081 100644
--- a/pkg/cmd/issue/delete/delete.go
+++ b/pkg/cmd/issue/delete/delete.go
@@ -21,7 +21,7 @@ type DeleteOptions struct {
 	BaseRepo   func() (ghrepo.Interface, error)
 	Prompter   iprompter
 
-	SelectorArg string
+	IssueNumber int
 	Confirmed   bool
 }
 
@@ -42,13 +42,23 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 		Short: "Delete issue",
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
 
-			if len(args) > 0 {
-				opts.SelectorArg = args[0]
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
 			}
 
+			opts.IssueNumber = issueNumber
+
 			if runF != nil {
 				return runF(opts)
 			}
@@ -71,7 +81,12 @@ func deleteRun(opts *DeleteOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go
index 1536800f0..19c9b5fa9 100644
--- a/pkg/cmd/issue/develop/develop.go
+++ b/pkg/cmd/issue/develop/develop.go
@@ -24,12 +24,12 @@ type DevelopOptions struct {
 	BaseRepo   func() (ghrepo.Interface, error)
 	Remotes    func() (context.Remotes, error)
 
-	IssueSelector string
-	Name          string
-	BranchRepo    string
-	BaseBranch    string
-	Checkout      bool
-	List          bool
+	IssueNumber int
+	Name        string
+	BranchRepo  string
+	BaseBranch  string
+	Checkout    bool
+	List        bool
 }
 
 func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.Command {
@@ -89,9 +89,23 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.
 			return nil
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
-			opts.IssueSelector = args[0]
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
+
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
+			}
+
+			opts.IssueNumber = issueNumber
+
 			if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--branch-repo`", opts.List, opts.BranchRepo != ""); err != nil {
 				return err
 			}
@@ -131,8 +145,13 @@ func developRun(opts *DevelopOptions) error {
 		return err
 	}
 
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
 	opts.IO.StartProgressIndicator()
-	issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"})
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"})
 	opts.IO.StopProgressIndicator()
 	if err != nil {
 		return err
@@ -141,16 +160,16 @@ func developRun(opts *DevelopOptions) error {
 	apiClient := api.NewClientFromHTTP(httpClient)
 
 	opts.IO.StartProgressIndicator()
-	err = api.CheckLinkedBranchFeature(apiClient, issueRepo.RepoHost())
+	err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost())
 	opts.IO.StopProgressIndicator()
 	if err != nil {
 		return err
 	}
 
 	if opts.List {
-		return developRunList(opts, apiClient, issueRepo, issue)
+		return developRunList(opts, apiClient, baseRepo, issue)
 	}
-	return developRunCreate(opts, apiClient, issueRepo, issue)
+	return developRunCreate(opts, apiClient, baseRepo, issue)
 }
 
 func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go
index 18067319f..8386cbcfa 100644
--- a/pkg/cmd/issue/edit/edit.go
+++ b/pkg/cmd/issue/edit/edit.go
@@ -5,9 +5,12 @@ import (
 	"net/http"
 	"sort"
 	"sync"
+	"time"
 
 	"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"
 	"github.com/cli/cli/v2/internal/text"
 	shared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
@@ -22,13 +25,14 @@ type EditOptions struct {
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
 	Prompter   prShared.EditPrompter
+	Detector   fd.Detector
 
 	DetermineEditor    func() (string, error)
 	FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error
 	EditFieldsSurvey   func(prShared.EditPrompter, *prShared.Editable, string) error
 	FetchOptions       func(*api.Client, ghrepo.Interface, *prShared.Editable) error
 
-	SelectorArgs []string
+	IssueNumbers []int
 	Interactive  bool
 
 	prShared.Editable
@@ -69,10 +73,22 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 		`),
 		Args: cobra.MinimumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
+			issueNumbers, baseRepo, err := shared.ParseIssuesFromArgs(args)
+			if err != nil {
+				return err
+			}
 
-			opts.SelectorArgs = args
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
+			}
+
+			opts.IssueNumbers = issueNumbers
 
 			flags := cmd.Flags()
 
@@ -134,7 +150,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 				return cmdutil.FlagErrorf("field to edit flag required when not running interactively")
 			}
 
-			if opts.Interactive && len(opts.SelectorArgs) > 1 {
+			if opts.Interactive && len(opts.IssueNumbers) > 1 {
 				return cmdutil.FlagErrorf("multiple issues cannot be edited interactively")
 			}
 
@@ -167,6 +183,11 @@ func editRun(opts *EditOptions) error {
 		return err
 	}
 
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
 	// Prompt the user which fields they'd like to edit.
 	editable := opts.Editable
 	if opts.Interactive {
@@ -184,7 +205,18 @@ func editRun(opts *EditOptions) error {
 		lookupFields = append(lookupFields, "labels")
 	}
 	if editable.Projects.Edited {
-		lookupFields = append(lookupFields, "projectCards")
+		// TODO projectsV1Deprecation
+		// Remove this section as we should no longer add projectCards
+		if opts.Detector == nil {
+			cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
+			opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
+		}
+
+		projectsV1Support := opts.Detector.ProjectsV1()
+		if projectsV1Support == gh.ProjectsV1Supported {
+			lookupFields = append(lookupFields, "projectCards")
+		}
+
 		lookupFields = append(lookupFields, "projectItems")
 	}
 	if editable.Milestone.Edited {
@@ -192,7 +224,7 @@ func editRun(opts *EditOptions) error {
 	}
 
 	// Get all specified issues and make sure they are within the same repo.
-	issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields)
+	issues, err := shared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields)
 	if err != nil {
 		return err
 	}
@@ -200,7 +232,7 @@ func editRun(opts *EditOptions) error {
 	// Fetch editable shared fields once for all issues.
 	apiClient := api.NewClientFromHTTP(httpClient)
 	opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
-	err = opts.FetchOptions(apiClient, repo, &editable)
+	err = opts.FetchOptions(apiClient, baseRepo, &editable)
 	opts.IO.StopProgressIndicator()
 	if err != nil {
 		return err
@@ -250,7 +282,7 @@ func editRun(opts *EditOptions) error {
 		go func(issue *api.Issue) {
 			defer g.Done()
 
-			err := prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable)
+			err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable)
 			if err != nil {
 				failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err)
 				return
diff --git a/pkg/cmd/issue/lock/lock.go b/pkg/cmd/issue/lock/lock.go
index 4e0dac058..2f332d21d 100644
--- a/pkg/cmd/issue/lock/lock.go
+++ b/pkg/cmd/issue/lock/lock.go
@@ -19,6 +19,7 @@ import (
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -99,20 +100,33 @@ type LockOptions struct {
 
 	ParentCmd   string
 	Reason      string
-	SelectorArg string
+	IssueNumber int
 	Interactive bool
 }
 
-func (opts *LockOptions) setCommonOptions(f *cmdutil.Factory, args []string) {
+func (opts *LockOptions) setCommonOptions(f *cmdutil.Factory, args []string) error {
 	opts.IO = f.IOStreams
 	opts.HttpClient = f.HttpClient
 	opts.Config = f.Config
 
-	// support `-R, --repo` override
-	opts.BaseRepo = f.BaseRepo
+	issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+	if err != nil {
+		return err
+	}
 
-	opts.SelectorArg = args[0]
+	// If the args provided the base repo then use that directly.
+	if baseRepo, present := baseRepo.Value(); present {
+		opts.BaseRepo = func() (ghrepo.Interface, error) {
+			return baseRepo, nil
+		}
+	} else {
+		// support `-R, --repo` override
+		opts.BaseRepo = f.BaseRepo
+	}
 
+	opts.IssueNumber = issueNumber
+
+	return nil
 }
 
 func NewCmdLock(f *cmdutil.Factory, parentName string, runF func(string, *LockOptions) error) *cobra.Command {
@@ -129,7 +143,9 @@ func NewCmdLock(f *cmdutil.Factory, parentName string, runF func(string, *LockOp
 		Short: short,
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.setCommonOptions(f, args)
+			if err := opts.setCommonOptions(f, args); err != nil {
+				return err
+			}
 
 			reasonProvided := cmd.Flags().Changed("reason")
 			if reasonProvided {
@@ -172,7 +188,9 @@ func NewCmdUnlock(f *cmdutil.Factory, parentName string, runF func(string, *Lock
 		Short: short,
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.setCommonOptions(f, args)
+			if err := opts.setCommonOptions(f, args); err != nil {
+				return err
+			}
 
 			if runF != nil {
 				return runF(Unlock, opts)
@@ -214,13 +232,18 @@ func lockRun(state string, opts *LockOptions) error {
 		return err
 	}
 
-	issuePr, baseRepo, err := issueShared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, fields())
-
-	parent := alias[opts.ParentCmd]
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
 
+	issuePr, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, fields())
 	if err != nil {
 		return err
-	} else if parent.Typename != issuePr.Typename {
+	}
+
+	parent := alias[opts.ParentCmd]
+	if parent.Typename != issuePr.Typename {
 		currentType := alias[parent.Typename]
 		correctType := alias[issuePr.Typename]
 
diff --git a/pkg/cmd/issue/pin/pin.go b/pkg/cmd/issue/pin/pin.go
index dfb11a881..290bec507 100644
--- a/pkg/cmd/issue/pin/pin.go
+++ b/pkg/cmd/issue/pin/pin.go
@@ -20,7 +20,7 @@ type PinOptions struct {
 	Config      func() (gh.Config, error)
 	IO          *iostreams.IOStreams
 	BaseRepo    func() (ghrepo.Interface, error)
-	SelectorArg string
+	IssueNumber int
 }
 
 func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command {
@@ -51,8 +51,22 @@ func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command
 		`),
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.BaseRepo = f.BaseRepo
-			opts.SelectorArg = args[0]
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
+
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
+			}
+
+			opts.IssueNumber = issueNumber
 
 			if runF != nil {
 				return runF(opts)
@@ -73,7 +87,12 @@ func pinRun(opts *PinOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "isPinned"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go
index 92f18a7d9..f01a8eafc 100644
--- a/pkg/cmd/issue/reopen/reopen.go
+++ b/pkg/cmd/issue/reopen/reopen.go
@@ -21,7 +21,7 @@ type ReopenOptions struct {
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
 
-	SelectorArg string
+	IssueNumber int
 	Comment     string
 }
 
@@ -37,13 +37,23 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
 		Short: "Reopen issue",
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
 
-			if len(args) > 0 {
-				opts.SelectorArg = args[0]
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
 			}
 
+			opts.IssueNumber = issueNumber
+
 			if runF != nil {
 				return runF(opts)
 			}
@@ -64,7 +74,12 @@ func reopenRun(opts *ReopenOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go
index 0c56ffd2c..24a5137e3 100644
--- a/pkg/cmd/issue/shared/display.go
+++ b/pkg/cmd/issue/shared/display.go
@@ -38,13 +38,13 @@ func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCou
 		}
 		table.AddField(text.RemoveExcessiveWhitespace(issue.Title))
 		table.AddField(issueLabelList(&issue, cs, isTTY))
-		table.AddTimeField(now, issue.UpdatedAt, cs.Gray)
+		table.AddTimeField(now, issue.UpdatedAt, cs.Muted)
 		table.EndRow()
 	}
 	_ = table.Render()
 	remaining := totalCount - len(issues)
 	if remaining > 0 {
-		fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining)
+		fmt.Fprintf(io.Out, cs.Muted("%sAnd %d more\n"), prefix, remaining)
 	}
 }
 
@@ -56,7 +56,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme, colorize bool)
 	labelNames := make([]string, 0, len(issue.Labels.Nodes))
 	for _, label := range issue.Labels.Nodes {
 		if colorize {
-			labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
+			labelNames = append(labelNames, cs.Label(label.Color, label.Name))
 		} else {
 			labelNames = append(labelNames, label.Name)
 		}
diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go
index be79f9a73..5c477363b 100644
--- a/pkg/cmd/issue/shared/lookup.go
+++ b/pkg/cmd/issue/shared/lookup.go
@@ -13,136 +13,129 @@ import (
 	"github.com/cli/cli/v2/api"
 	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	o "github.com/cli/cli/v2/pkg/option"
 	"github.com/cli/cli/v2/pkg/set"
 	"golang.org/x/sync/errgroup"
 )
 
-// IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields
-// could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError.
-func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) {
-	issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	if baseRepo == nil {
-		var err error
-		if baseRepo, err = baseRepoFn(); err != nil {
-			return nil, nil, err
-		}
-	}
-
-	issue, err := findIssueOrPR(httpClient, baseRepo, issueNumber, fields)
-	return issue, baseRepo, err
-}
+var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)`)
 
-// IssuesFromArgsWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields
-// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError.
-func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) {
-	var issuesRepo ghrepo.Interface
-	issueNumbers := make([]int, 0, len(args))
+func ParseIssuesFromArgs(args []string) ([]int, o.Option[ghrepo.Interface], error) {
+	var repo o.Option[ghrepo.Interface]
+	issueNumbers := make([]int, len(args))
 
-	for _, arg := range args {
-		issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
+	for i, arg := range args {
+		// For each argument, parse the issue number and an optional repo
+		issueNumber, issueRepo, err := ParseIssueFromArg(arg)
 		if err != nil {
-			return nil, nil, err
+			return nil, o.None[ghrepo.Interface](), err
 		}
 
-		issueNumbers = append(issueNumbers, issueNumber)
-		if baseRepo == nil {
-			var err error
-			if baseRepo, err = baseRepoFn(); err != nil {
-				return nil, nil, err
-			}
+		// if this is our first issue repo found, then we need to set it
+		if repo.IsNone() {
+			repo = issueRepo
 		}
 
-		if issuesRepo == nil {
-			issuesRepo = baseRepo
-			continue
+		// if there is an issue repo returned, then we need to check if it is the same as the previous one
+		if issueRepo.IsSome() && repo.IsSome() {
+			// Unwraps are safe because we've checked for presence above
+			if !ghrepo.IsSame(repo.Unwrap(), issueRepo.Unwrap()) {
+				return nil, o.None[ghrepo.Interface](), fmt.Errorf(
+					"multiple issues must be in same repo: found %q, expected %q",
+					ghrepo.FullName(issueRepo.Unwrap()),
+					ghrepo.FullName(repo.Unwrap()),
+				)
+			}
 		}
 
-		if !ghrepo.IsSame(issuesRepo, baseRepo) {
-			return nil, nil, fmt.Errorf(
-				"multiple issues must be in same repo: found %q, expected %q",
-				ghrepo.FullName(baseRepo),
-				ghrepo.FullName(issuesRepo),
-			)
-		}
+		// add the issue number to the list
+		issueNumbers[i] = issueNumber
 	}
 
-	issuesChan := make(chan *api.Issue, len(args))
-	g := errgroup.Group{}
-	for _, num := range issueNumbers {
-		issueNumber := num
-		g.Go(func() error {
-			issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields)
-			if err != nil {
-				return err
-			}
+	return issueNumbers, repo, nil
+}
 
-			issuesChan <- issue
-			return nil
-		})
+func ParseIssueFromArg(arg string) (int, o.Option[ghrepo.Interface], error) {
+	issueLocator := tryParseIssueFromURL(arg)
+	if issueLocator, present := issueLocator.Value(); present {
+		return issueLocator.issueNumber, o.Some(issueLocator.repo), nil
 	}
 
-	err := g.Wait()
-	close(issuesChan)
-
+	issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#"))
 	if err != nil {
-		return nil, nil, err
+		return 0, o.None[ghrepo.Interface](), fmt.Errorf("invalid issue format: %q", arg)
 	}
 
-	issues := make([]*api.Issue, 0, len(args))
-	for issue := range issuesChan {
-		issues = append(issues, issue)
-	}
-
-	return issues, issuesRepo, nil
+	return issueNumber, o.None[ghrepo.Interface](), nil
 }
 
-var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)`)
+type issueLocator struct {
+	issueNumber int
+	repo        ghrepo.Interface
+}
 
-func issueMetadataFromURL(s string) (int, ghrepo.Interface) {
-	u, err := url.Parse(s)
+// tryParseIssueFromURL tries to parse an issue number and repo from a URL.
+func tryParseIssueFromURL(maybeURL string) o.Option[issueLocator] {
+	u, err := url.Parse(maybeURL)
 	if err != nil {
-		return 0, nil
+		return o.None[issueLocator]()
 	}
 
 	if u.Scheme != "https" && u.Scheme != "http" {
-		return 0, nil
+		return o.None[issueLocator]()
 	}
 
 	m := issueURLRE.FindStringSubmatch(u.Path)
 	if m == nil {
-		return 0, nil
+		return o.None[issueLocator]()
 	}
 
 	repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
 	issueNumber, _ := strconv.Atoi(m[3])
-	return issueNumber, repo
+	return o.Some(issueLocator{
+		issueNumber: issueNumber,
+		repo:        repo,
+	})
 }
 
-// Returns the issue number and repo if the issue URL is provided.
-// If only the issue number is provided, returns the number and nil repo.
-func IssueNumberAndRepoFromArg(arg string) (int, ghrepo.Interface, error) {
-	issueNumber, baseRepo := issueMetadataFromURL(arg)
+type PartialLoadError struct {
+	error
+}
 
-	if issueNumber == 0 {
-		var err error
-		issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#"))
-		if err != nil {
-			return 0, nil, fmt.Errorf("invalid issue format: %q", arg)
-		}
+// FindIssuesOrPRs loads 1 or more issues or pull requests with the specified fields. If some of the fields
+// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError.
+func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumbers []int, fields []string) ([]*api.Issue, error) {
+	issuesChan := make(chan *api.Issue, len(issueNumbers))
+	g := errgroup.Group{}
+	for _, num := range issueNumbers {
+		issueNumber := num
+		g.Go(func() error {
+			issue, err := FindIssueOrPR(httpClient, repo, issueNumber, fields)
+			if err != nil {
+				return err
+			}
+
+			issuesChan <- issue
+			return nil
+		})
 	}
 
-	return issueNumber, baseRepo, nil
-}
+	err := g.Wait()
+	close(issuesChan)
 
-type PartialLoadError struct {
-	error
+	if err != nil {
+		return nil, err
+	}
+
+	issues := make([]*api.Issue, 0, len(issueNumbers))
+	for issue := range issuesChan {
+		issues = append(issues, issue)
+	}
+
+	return issues, nil
 }
 
-func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) {
+func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) {
 	fieldSet := set.NewStringSet()
 	fieldSet.AddValues(fields)
 	if fieldSet.Contains("stateReason") {
diff --git a/pkg/cmd/issue/transfer/transfer.go b/pkg/cmd/issue/transfer/transfer.go
index 140d02b91..a6dfb9b23 100644
--- a/pkg/cmd/issue/transfer/transfer.go
+++ b/pkg/cmd/issue/transfer/transfer.go
@@ -20,7 +20,7 @@ type TransferOptions struct {
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
 
-	IssueSelector    string
+	IssueNumber      int
 	DestRepoSelector string
 }
 
@@ -36,8 +36,23 @@ func NewCmdTransfer(f *cmdutil.Factory, runF func(*TransferOptions) error) *cobr
 		Short: "Transfer issue to another repository",
 		Args:  cmdutil.ExactArgs(2, "issue and destination repository are required"),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.BaseRepo = f.BaseRepo
-			opts.IssueSelector = args[0]
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
+
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
+			}
+
+			opts.IssueNumber = issueNumber
+
 			opts.DestRepoSelector = args[1]
 
 			if runF != nil {
@@ -57,7 +72,12 @@ func transferRun(opts *TransferOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/unpin/unpin.go b/pkg/cmd/issue/unpin/unpin.go
index 3ac28d47c..ca22aa82e 100644
--- a/pkg/cmd/issue/unpin/unpin.go
+++ b/pkg/cmd/issue/unpin/unpin.go
@@ -16,11 +16,12 @@ import (
 )
 
 type UnpinOptions struct {
-	HttpClient  func() (*http.Client, error)
-	Config      func() (gh.Config, error)
-	IO          *iostreams.IOStreams
-	BaseRepo    func() (ghrepo.Interface, error)
-	SelectorArg string
+	HttpClient func() (*http.Client, error)
+	Config     func() (gh.Config, error)
+	IO         *iostreams.IOStreams
+	BaseRepo   func() (ghrepo.Interface, error)
+
+	IssueNumber int
 }
 
 func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Command {
@@ -51,8 +52,22 @@ func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Comm
 		`),
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.BaseRepo = f.BaseRepo
-			opts.SelectorArg = args[0]
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
+
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
+			}
+
+			opts.IssueNumber = issueNumber
 
 			if runF != nil {
 				return runF(opts)
@@ -73,7 +88,12 @@ func unpinRun(opts *UnpinOptions) error {
 		return err
 	}
 
-	issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"})
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "isPinned"})
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go
index b188c6a4c..a9e25513b 100644
--- a/pkg/cmd/issue/view/view.go
+++ b/pkg/cmd/issue/view/view.go
@@ -12,8 +12,11 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/browser"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
 	prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -28,8 +31,9 @@ type ViewOptions struct {
 	IO         *iostreams.IOStreams
 	BaseRepo   func() (ghrepo.Interface, error)
 	Browser    browser.Browser
+	Detector   fd.Detector
 
-	SelectorArg string
+	IssueNumber int
 	WebMode     bool
 	Comments    bool
 	Exporter    cmdutil.Exporter
@@ -55,13 +59,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 		`, "`"),
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
+			issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
+			if err != nil {
+				return err
+			}
 
-			if len(args) > 0 {
-				opts.SelectorArg = args[0]
+			// If the args provided the base repo then use that directly.
+			if baseRepo, present := baseRepo.Value(); present {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return baseRepo, nil
+				}
+			} else {
+				// support `-R, --repo` override
+				opts.BaseRepo = f.BaseRepo
 			}
 
+			opts.IssueNumber = issueNumber
+
 			if runF != nil {
 				return runF(opts)
 			}
@@ -78,7 +92,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 
 var defaultFields = []string{
 	"number", "url", "state", "createdAt", "title", "body", "author", "milestone",
-	"assignees", "labels", "projectCards", "reactionGroups", "lastComment", "stateReason",
+	"assignees", "labels", "reactionGroups", "lastComment", "stateReason",
 }
 
 func viewRun(opts *ViewOptions) error {
@@ -87,6 +101,11 @@ func viewRun(opts *ViewOptions) error {
 		return err
 	}
 
+	baseRepo, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
 	lookupFields := set.NewStringSet()
 	if opts.Exporter != nil {
 		lookupFields.AddValues(opts.Exporter.Fields())
@@ -98,12 +117,35 @@ func viewRun(opts *ViewOptions) error {
 			lookupFields.Add("comments")
 			lookupFields.Remove("lastComment")
 		}
+
+		// TODO projectsV1Deprecation
+		// Remove this section as we should no longer add projectCards
+		if opts.Detector == nil {
+			cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
+			opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
+		}
+
+		projectsV1Support := opts.Detector.ProjectsV1()
+		if projectsV1Support == gh.ProjectsV1Supported {
+			lookupFields.Add("projectCards")
+		}
 	}
 
 	opts.IO.DetectTerminalTheme()
 
 	opts.IO.StartProgressIndicator()
-	issue, baseRepo, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice())
+	lookupFields.Add("id")
+
+	issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, lookupFields.ToSlice())
+	if err != nil {
+		return err
+	}
+
+	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)
+	}
 	opts.IO.StopProgressIndicator()
 	if err != nil {
 		var loadErr *issueShared.PartialLoadError
@@ -143,24 +185,6 @@ func viewRun(opts *ViewOptions) error {
 	return printRawIssuePreview(opts.IO.Out, issue)
 }
 
-func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string) (*api.Issue, ghrepo.Interface, error) {
-	fieldSet := set.NewStringSet()
-	fieldSet.AddValues(fields)
-	fieldSet.Add("id")
-
-	issue, repo, err := issueShared.IssueFromArgWithFields(client, baseRepoFn, selector, fieldSet.ToSlice())
-	if err != nil {
-		return issue, repo, err
-	}
-
-	if fieldSet.Contains("comments") {
-		// FIXME: this re-fetches the comments connection even though the initial set of 100 were
-		// fetched in the previous request.
-		err = preloadIssueComments(client, repo, issue)
-	}
-	return issue, repo, err
-}
-
 func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
 	assignees := issueAssigneeList(*issue)
 	labels := issueLabelList(issue, nil)
@@ -228,7 +252,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
 	var md string
 	var err error
 	if issue.Body == "" {
-		md = fmt.Sprintf("\n  %s\n\n", cs.Gray("No description provided"))
+		md = fmt.Sprintf("\n  %s\n\n", cs.Muted("No description provided"))
 	} else {
 		md, err = markdown.Render(issue.Body,
 			markdown.WithTheme(opts.IO.TerminalTheme()),
@@ -250,7 +274,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
 	}
 
 	// Footer
-	fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL)
+	fmt.Fprintf(out, cs.Muted("View this issue on GitHub: %s\n"), issue.URL)
 
 	return nil
 }
@@ -317,7 +341,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string {
 		if cs == nil {
 			labelNames[i] = label.Name
 		} else {
-			labelNames[i] = cs.HexToRGB(label.Color, label.Name)
+			labelNames[i] = cs.Label(label.Color, label.Name)
 		}
 	}
 
diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go
index fe1e9cdb7..4fe2f5ec9 100644
--- a/pkg/cmd/label/list.go
+++ b/pkg/cmd/label/list.go
@@ -137,7 +137,12 @@ func printLabels(io *iostreams.IOStreams, labels []label) error {
 	table := tableprinter.New(io, tableprinter.WithHeader("NAME", "DESCRIPTION", "COLOR"))
 
 	for _, label := range labels {
-		table.AddField(label.Name, tableprinter.WithColor(cs.ColorFromRGB(label.Color)))
+		// Colorize the label using tableprinter's WithColor function for it to handle non-TTY situations
+		labelColor := tableprinter.WithColor(func(s string) string {
+			return cs.Label(label.Color, s)
+		})
+
+		table.AddField(label.Name, labelColor)
 		table.AddField(label.Description)
 		table.AddField("#" + label.Color)
 
diff --git a/pkg/cmd/pr/checks/output.go b/pkg/cmd/pr/checks/output.go
index 5d2f080c6..24105c3e2 100644
--- a/pkg/cmd/pr/checks/output.go
+++ b/pkg/cmd/pr/checks/output.go
@@ -30,7 +30,7 @@ func addRow(tp *tableprinter.TablePrinter, io *iostreams.IOStreams, o check) {
 		markColor = cs.Yellow
 	case "skipping", "cancel":
 		mark = "-"
-		markColor = cs.Gray
+		markColor = cs.Muted
 	}
 
 	if io.IsStdoutTTY() {
diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go
index 0066bbc6e..7f960bce4 100644
--- a/pkg/cmd/pr/create/create.go
+++ b/pkg/cmd/pr/create/create.go
@@ -25,6 +25,7 @@ import (
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/cli/cli/v2/pkg/markdown"
+	o "github.com/cli/cli/v2/pkg/option"
 	"github.com/spf13/cobra"
 )
 
@@ -72,18 +73,107 @@ type CreateOptions struct {
 	DryRun bool
 }
 
+// creationRefs is an interface that provides the necessary information for creating a pull request in the API.
+// Upcasting to concrete implementations can provide further context on other operations (forking and pushing).
+type creationRefs interface {
+	// QualifiedHeadRef returns a stringified form of the head ref, varying depending
+	// on whether the head ref is in the same repository as the base ref. If they are
+	// the same repository, we return the branch name only. If they are different repositories,
+	// we return the owner and branch name in the form <owner>:<branch>.
+	QualifiedHeadRef() string
+	// UnqualifiedHeadRef returns a head ref in the form of the branch name only.
+	UnqualifiedHeadRef() string
+	//BaseRef returns the base branch name.
+	BaseRef() string
+
+	// While the only thing really required from an api.Repository is the repository ID, changing that
+	// would require changing the API function signatures, and the refactor that introduced this refs
+	// type is already large enough.
+	BaseRepo() *api.Repository
+}
+
+type baseRefs struct {
+	baseRepo       *api.Repository
+	baseBranchName string
+}
+
+func (r baseRefs) BaseRef() string {
+	return r.baseBranchName
+}
+
+func (r baseRefs) BaseRepo() *api.Repository {
+	return r.baseRepo
+}
+
+// skipPushRefs indicate to handlePush that no pushing is required.
+type skipPushRefs struct {
+	baseRefs
+
+	qualifiedHeadRef shared.QualifiedHeadRef
+}
+
+func (r skipPushRefs) QualifiedHeadRef() string {
+	return r.qualifiedHeadRef.String()
+}
+
+func (r skipPushRefs) UnqualifiedHeadRef() string {
+	return r.qualifiedHeadRef.BranchName()
+}
+
+// pushableRefs indicate to handlePush that pushing is required,
+// and provide further information (HeadRepo) on where that push
+// should go.
+type pushableRefs struct {
+	baseRefs
+
+	headRepo       ghrepo.Interface
+	headBranchName string
+}
+
+func (r pushableRefs) QualifiedHeadRef() string {
+	if ghrepo.IsSame(r.headRepo, r.baseRepo) {
+		return r.headBranchName
+	}
+	return fmt.Sprintf("%s:%s", r.headRepo.RepoOwner(), r.headBranchName)
+}
+
+func (r pushableRefs) UnqualifiedHeadRef() string {
+	return r.headBranchName
+}
+
+func (r pushableRefs) HeadRepo() ghrepo.Interface {
+	return r.headRepo
+}
+
+// forkableRefs indicate to handlePush that forking is required before
+// pushing. The expectation is that after forking, this is converted to
+// pushableRefs. We could go very OOP and have a Fork method on this
+// struct that returns a pushableRefs but then we'd need to embed an API client
+// and it just seems nice that it is a simple bag of data.
+type forkableRefs struct {
+	baseRefs
+
+	qualifiedHeadRef shared.QualifiedHeadRef
+}
+
+func (r forkableRefs) QualifiedHeadRef() string {
+	return r.qualifiedHeadRef.String()
+}
+
+func (r forkableRefs) UnqualifiedHeadRef() string {
+	return r.qualifiedHeadRef.BranchName()
+}
+
+// CreateContext stores contextual data about the creation process and is for building up enough
+// data to create a pull request.
 type CreateContext struct {
-	// This struct stores contextual data about the creation process and is for building up enough
-	// data to create a pull request
-	RepoContext        *ghContext.ResolvedRemotes
-	BaseRepo           *api.Repository
-	HeadRepo           ghrepo.Interface
+	ResolvedRemotes *ghContext.ResolvedRemotes
+	PRRefs          creationRefs
+	// BaseTrackingBranch is perhaps a slightly leaky abstraction in the presence
+	// of PRRefs, but a huge amount of refactoring was done to introduce that struct,
+	// and this is a small price to pay for the convenience of not having to do a lot
+	// more design.
 	BaseTrackingBranch string
-	BaseBranch         string
-	HeadBranch         string
-	HeadBranchLabel    string
-	HeadRemote         *ghContext.Remote
-	IsPushEnabled      bool
 	Client             *api.Client
 	GitClient          *git.Client
 }
@@ -113,6 +203,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 			to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to
 			explicitly skip any forking or pushing behavior.
 
+			%[1]s--head%[1]s supports %[1]s<user>:<branch>%[1]s syntax to select a head repo owned by %[1]s<user>%[1]s.
+			Using an organization as the %[1]s<user>%[1]s is currently not supported.
+			For more information, see <https://github.com/cli/cli/issues/10093>
+
 			A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and
 			%[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits.
 			It's important to notice that if the %[1]s--title%[1]s and/or %[1]s--body%[1]s are also provided
@@ -310,8 +404,8 @@ func createRun(opts *CreateOptions) error {
 	}
 
 	existingPR, _, err := opts.Finder.Find(shared.FindOptions{
-		Selector:   ctx.HeadBranchLabel,
-		BaseBranch: ctx.BaseBranch,
+		Selector:   ctx.PRRefs.QualifiedHeadRef(),
+		BaseBranch: ctx.PRRefs.BaseRef(),
 		States:     []string{"OPEN"},
 		Fields:     []string{"url"},
 	})
@@ -321,7 +415,7 @@ func createRun(opts *CreateOptions) error {
 	}
 	if err == nil {
 		return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
-			ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL)
+			ctx.PRRefs.QualifiedHeadRef(), ctx.PRRefs.BaseRef(), existingPR.URL)
 	}
 
 	message := "\nCreating pull request for %s into %s in %s\n\n"
@@ -336,9 +430,9 @@ func createRun(opts *CreateOptions) error {
 
 	if opts.IO.CanPrompt() {
 		fmt.Fprintf(opts.IO.ErrOut, message,
-			cs.Cyan(ctx.HeadBranchLabel),
-			cs.Cyan(ctx.BaseBranch),
-			ghrepo.FullName(ctx.BaseRepo))
+			cs.Cyan(ctx.PRRefs.QualifiedHeadRef()),
+			cs.Cyan(ctx.PRRefs.BaseRef()),
+			ghrepo.FullName(ctx.PRRefs.BaseRepo()))
 	}
 
 	if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) {
@@ -346,7 +440,8 @@ func createRun(opts *CreateOptions) error {
 		if err != nil {
 			return err
 		}
-		return submitPR(*opts, *ctx, *state)
+		// TODO wm: revisit project support
+		return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported)
 	}
 
 	if opts.RecoverFile != "" {
@@ -361,7 +456,7 @@ func createRun(opts *CreateOptions) error {
 		action = shared.SubmitDraftAction
 	}
 
-	tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
+	tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
 
 	if opts.EditorMode {
 		if opts.Template != "" {
@@ -429,7 +524,7 @@ func createRun(opts *CreateOptions) error {
 		}
 
 		allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun
-		allowMetadata := ctx.BaseRepo.ViewerCanTriage()
+		allowMetadata := ctx.PRRefs.BaseRepo().ViewerCanTriage()
 		action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft)
 		if err != nil {
 			return fmt.Errorf("unable to confirm: %w", err)
@@ -439,10 +534,11 @@ func createRun(opts *CreateOptions) error {
 			fetcher := &shared.MetadataFetcher{
 				IO:        opts.IO,
 				APIClient: client,
-				Repo:      ctx.BaseRepo,
+				Repo:      ctx.PRRefs.BaseRepo(),
 				State:     state,
 			}
-			err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state)
+			// TODO wm: revisit project support
+			err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported)
 			if err != nil {
 				return err
 			}
@@ -471,11 +567,13 @@ func createRun(opts *CreateOptions) error {
 
 	if action == shared.SubmitDraftAction {
 		state.Draft = true
-		return submitPR(*opts, *ctx, *state)
+		// TODO wm: revisit project support
+		return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported)
 	}
 
 	if action == shared.SubmitAction {
-		return submitPR(*opts, *ctx, *state)
+		// TODO wm: revisit project support
+		return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported)
 	}
 
 	err = errors.New("expected to cancel, preview, or submit")
@@ -485,11 +583,7 @@ func createRun(opts *CreateOptions) error {
 var regexPattern = regexp.MustCompile(`(?m)^`)
 
 func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error {
-	baseRef := ctx.BaseTrackingBranch
-	headRef := ctx.HeadBranch
-	gitClient := ctx.GitClient
-
-	commits, err := gitClient.Commits(context.Background(), baseRef, headRef)
+	commits, err := ctx.GitClient.Commits(context.Background(), ctx.BaseTrackingBranch, ctx.PRRefs.UnqualifiedHeadRef())
 	if err != nil {
 		return err
 	}
@@ -498,7 +592,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
 		state.Title = commits[len(commits)-1].Title
 		state.Body = commits[len(commits)-1].Body
 	} else {
-		state.Title = humanize(headRef)
+		state.Title = humanize(ctx.PRRefs.UnqualifiedHeadRef())
 		var body strings.Builder
 		for i := len(commits) - 1; i >= 0; i-- {
 			fmt.Fprintf(&body, "- **%s**\n", commits[i].Title)
@@ -518,103 +612,26 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
 	return nil
 }
 
-// TODO: Replace with the finder's PullRequestRefs struct
-// trackingRef represents a ref for a remote tracking branch.
-type trackingRef struct {
-	remoteName string
-	branchName string
-}
-
-func (r trackingRef) String() string {
-	return "refs/remotes/" + r.remoteName + "/" + r.branchName
-}
-
-func mustParseTrackingRef(text string) trackingRef {
-	parts := strings.SplitN(string(text), "/", 4)
-	// The only place this is called is tryDetermineTrackingRef, where we are reconstructing
-	// the same tracking ref we passed in. If it doesn't match the expected format, this is a
-	// programmer error we want to know about, so it's ok to panic.
-	if len(parts) != 4 {
-		panic(fmt.Errorf("tracking ref should have four parts: %s", text))
-	}
-	if parts[0] != "refs" || parts[1] != "remotes" {
-		panic(fmt.Errorf("tracking ref should start with refs/remotes/: %s", text))
-	}
-
-	return trackingRef{
-		remoteName: parts[2],
-		branchName: parts[3],
-	}
-}
-
-// tryDetermineTrackingRef is intended to try and find a remote branch on the same commit as the currently checked out
-// HEAD, i.e. the local branch. If there are multiple branches that might match, the first remote is chosen, which in
-// practice is determined by the sorting algorithm applied much earlier in the process, roughly "upstream", "github", "origin",
-// and then everything else unstably sorted.
-func tryDetermineTrackingRef(gitClient *git.Client, remotes ghContext.Remotes, localBranchName string, headBranchConfig git.BranchConfig) (trackingRef, bool) {
-	// To try and determine the tracking ref for a local branch, we first construct a collection of refs
-	// that might be tracking, given the current branch's config, and the list of known remotes.
-	refsForLookup := []string{"HEAD"}
-	if headBranchConfig.RemoteName != "" && headBranchConfig.MergeRef != "" {
-		tr := trackingRef{
-			remoteName: headBranchConfig.RemoteName,
-			branchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"),
-		}
-		refsForLookup = append(refsForLookup, tr.String())
-	}
-
-	for _, remote := range remotes {
-		tr := trackingRef{
-			remoteName: remote.Name,
-			branchName: localBranchName,
-		}
-		refsForLookup = append(refsForLookup, tr.String())
-	}
-
-	// Then we ask git for details about these refs, for example, refs/remotes/origin/trunk might return a hash
-	// for the remote tracking branch, trunk, for the remote, origin. If there is no ref, the git client returns
-	// no ref information.
-	//
-	// We also first check for the HEAD ref, so that we have the hash of the currently checked out commit.
-	resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup)
-
-	// If there is more than one resolved ref, that means that at least one ref was found in addition to the HEAD.
-	if len(resolvedRefs) > 1 {
-		headRef := resolvedRefs[0]
-		for _, r := range resolvedRefs[1:] {
-			// If the hash of the remote ref doesn't match the hash of HEAD then the remote branch is not in the same
-			// state, so it can't be used.
-			if r.Hash != headRef.Hash {
-				continue
-			}
-			// Otherwise we can parse the returned ref into a tracking ref and return that
-			return mustParseTrackingRef(r.Name), true
-		}
-	}
-
-	return trackingRef{}, false
-}
-
 func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) {
 	var milestoneTitles []string
 	if opts.Milestone != "" {
 		milestoneTitles = []string{opts.Milestone}
 	}
 
-	meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost())
+	meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost())
 	assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
 	if err != nil {
 		return nil, err
 	}
 
 	state := &shared.IssueMetadataState{
-		Type:       shared.PRMetadata,
-		Reviewers:  opts.Reviewers,
-		Assignees:  assignees,
-		Labels:     opts.Labels,
-		Projects:   opts.Projects,
-		Milestones: milestoneTitles,
-		Draft:      opts.IsDraft,
+		Type:          shared.PRMetadata,
+		Reviewers:     opts.Reviewers,
+		Assignees:     assignees,
+		Labels:        opts.Labels,
+		ProjectTitles: opts.Projects,
+		Milestones:    milestoneTitles,
+		Draft:         opts.IsDraft,
 	}
 
 	if opts.FillVerbose || opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided {
@@ -638,13 +655,14 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
 	if err != nil {
 		return nil, err
 	}
-	repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
+
+	resolvedRemotes, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
 	if err != nil {
 		return nil, err
 	}
 
 	var baseRepo *api.Repository
-	if br, err := repoContext.BaseRepo(opts.IO); err == nil {
+	if br, err := resolvedRemotes.BaseRepo(opts.IO); err == nil {
 		if r, ok := br.(*api.Repository); ok {
 			baseRepo = r
 		} else {
@@ -659,137 +677,284 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
 		return nil, err
 	}
 
-	isPushEnabled := false
-	headBranch := opts.HeadBranch
-	headBranchLabel := opts.HeadBranch
-	if headBranch == "" {
-		headBranch, err = opts.Branch()
+	// This closure provides an easy way to instantiate a CreateContext with everything other than
+	// the refs. This probably indicates that CreateContext could do with some rework, but the refactor
+	// to introduce PRRefs is already large enough.
+	var newCreateContext = func(refs creationRefs) *CreateContext {
+		baseTrackingBranch := refs.BaseRef()
+
+		// The baseTrackingBranch is used later for a command like:
+		// `git commit upstream/main feature` in order to create a PR message showing the commits
+		// between these two refs. I'm not really sure what is expected to happen if we don't have a remote,
+		// which seems like it would be possible with a command `gh pr create --repo owner/repo-that-is-not-a-remote`.
+		// In that case, we might just have a mess? In any case, this is what the old code did, so I don't want to change
+		// it as part of an already large refactor.
+		baseRemote, _ := resolvedRemotes.RemoteForRepo(baseRepo)
+		if baseRemote != nil {
+			baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseTrackingBranch)
+		}
+
+		return &CreateContext{
+			ResolvedRemotes:    resolvedRemotes,
+			Client:             client,
+			GitClient:          opts.GitClient,
+			PRRefs:             refs,
+			BaseTrackingBranch: baseTrackingBranch,
+		}
+	}
+
+	// If the user provided a head branch we're going to use that without any interrogation
+	// of git. The value can take the form of <branch> or <user>:<branch>. In the former case, the
+	// PR base and head repos are the same. In the latter case we don't know the head repo
+	// (though we could look it up in the API) but fortunately we don't need to because the API
+	// will resolve this for us when we create the pull request. This is possible because
+	// users can only have a single fork in their namespace, and organizations don't work at all with this ref format.
+	//
+	// Note that providing the head branch in this way indicates that we shouldn't push the branch,
+	// and we indicate that via the returned type as well.
+	if opts.HeadBranch != "" {
+		qualifiedHeadRef, err := shared.ParseQualifiedHeadRef(opts.HeadBranch)
+		if err != nil {
+			return nil, err
+		}
+
+		branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), qualifiedHeadRef.BranchName())
 		if err != nil {
-			return nil, fmt.Errorf("could not determine the current branch: %w", err)
+			return nil, err
 		}
-		headBranchLabel = headBranch
-		isPushEnabled = true
-	} else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
-		headBranch = headBranch[idx+1:]
+
+		baseBranch := opts.BaseBranch
+		if baseBranch == "" {
+			baseBranch = branchConfig.MergeBase
+		}
+		if baseBranch == "" {
+			baseBranch = baseRepo.DefaultBranchRef.Name
+		}
+
+		return newCreateContext(skipPushRefs{
+			qualifiedHeadRef: qualifiedHeadRef,
+			baseRefs: baseRefs{
+				baseRepo:       baseRepo,
+				baseBranchName: baseBranch,
+			},
+		}), nil
 	}
 
-	gitClient := opts.GitClient
-	if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
+	if ucc, err := opts.GitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
 		fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change"))
 	}
 
-	var headRepo ghrepo.Interface
-	var headRemote *ghContext.Remote
+	// If the user didn't provide a head branch then we're gettin' real. We're going to interrogate git
+	// and try to create refs that are pushable.
+	currentBranch, err := opts.Branch()
+	if err != nil {
+		return nil, fmt.Errorf("could not determine the current branch: %w", err)
+	}
 
-	headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch)
+	branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), currentBranch)
 	if err != nil {
 		return nil, err
 	}
-	if isPushEnabled {
-		// TODO: This doesn't respect the @{push} revision resolution or triagular workflows assembled with
-		// remote.pushDefault, or branch.<branchName>.pushremote config settings. The finder's ParsePRRefs
-		// may be able to replace this function entirely.
-		if trackingRef, found := tryDetermineTrackingRef(gitClient, remotes, headBranch, headBranchConfig); found {
-			isPushEnabled = false
-			if r, err := remotes.FindByName(trackingRef.remoteName); err == nil {
-				headRepo = r
-				headRemote = r
-				headBranchLabel = trackingRef.branchName
-				if !ghrepo.IsSame(baseRepo, headRepo) {
-					headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), trackingRef.branchName)
+
+	baseBranch := opts.BaseBranch
+	if baseBranch == "" {
+		baseBranch = branchConfig.MergeBase
+	}
+	if baseBranch == "" {
+		baseBranch = baseRepo.DefaultBranchRef.Name
+	}
+
+	// First we check with the git information we have to see if we can figure out the default
+	// head repo and remote branch name.
+	defaultPRHead, err := shared.TryDetermineDefaultPRHead(
+		// We requested the branch config already, so let's cache that
+		shared.CachedBranchConfigGitConfigClient{
+			CachedBranchConfig: branchConfig,
+			GitConfigClient:    opts.GitClient,
+		},
+		shared.NewRemoteToRepoResolver(opts.Remotes),
+		currentBranch,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// The baseRefs are always going to be the same from now on. If I could make this immutable I would!
+	baseRefs := baseRefs{
+		baseRepo:       baseRepo,
+		baseBranchName: baseBranch,
+	}
+
+	// If we were able to determine a head repo, then let's check that the remote tracking ref matches the SHA of
+	// HEAD. If it does, then we don't need to push, otherwise we'll need to ask the user to tell us where to push.
+	if headRepo, present := defaultPRHead.Repo.Value(); present {
+		// We may not find a remote because the git branch config may have a URL rather than a remote name.
+		// Ideally, we would return a sentinel error from RemoteForRepo that we could compare to, but the
+		// refactor that introduced this code was already large enough.
+		headRemote, _ := resolvedRemotes.RemoteForRepo(headRepo)
+		if headRemote != nil {
+			resolvedRefs, _ := opts.GitClient.ShowRefs(
+				context.Background(),
+				[]string{
+					"HEAD",
+					fmt.Sprintf("refs/remotes/%s/%s", headRemote.Name, defaultPRHead.BranchName),
+				},
+			)
+
+			// Two refs returned means we can compare HEAD to the remote tracking branch.
+			// If we had a matching ref, then we can skip pushing.
+			refsMatch := len(resolvedRefs) == 2 && resolvedRefs[0].Hash == resolvedRefs[1].Hash
+			if refsMatch {
+				qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(defaultPRHead.BranchName)
+				if headRepo.RepoOwner() != baseRepo.RepoOwner() {
+					qualifiedHeadRef = shared.NewQualifiedHeadRef(headRepo.RepoOwner(), defaultPRHead.BranchName)
 				}
+
+				return newCreateContext(skipPushRefs{
+					qualifiedHeadRef: qualifiedHeadRef,
+					baseRefs:         baseRefs,
+				}), nil
 			}
 		}
 	}
 
-	// otherwise, ask the user for the head repository using info obtained from the API
-	if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
-		pushableRepos, err := repoContext.HeadRepos()
-		if err != nil {
-			return nil, err
+	// If we didn't determine that the git indicated repo had the correct ref, we'll take a look at the other
+	// remotes and see whether any of them have the same SHA as HEAD. Now, at this point, you might be asking yourself:
+	// "Why didn't we collect all the SHAs with a single ShowRefs command above, for use in both cases?"
+	// ...
+	// That's because the code below has a bug that I've ported from the old code, in order to preserve the existing
+	// behaviour, and to limit the scope of an already large refactor. The intention of the original code was to loop
+	// over all the returned refs. However, as it turns out, our implementation of ShowRefs doesn't do that correctly.
+	// Since it provides the --verify flag, git will return the SHAs for refs up until it hits a ref that doesn't exist,
+	// at which point it bails out.
+	//
+	// Imagine you have a remotes "upstream" and "origin", and you have pushed your branch "feature" to "origin". Since
+	// the order of remotes is always guaranteed "upstream", "github", "origin", and then everything else unstably sorted,
+	// we will never get a SHA for origin, as refs/remotes/upstream/feature doesn't exist.
+	//
+	// Furthermore, when you really think about it, this code is a bit eager. What happens if you have the same SHA on
+	// remotes "origin" and "colleague", this will always offer origin. If it were "colleague-a" and "colleague-b", no
+	// order would be guaranteed between different invocations of pr create, because the order of remotes after "origin"
+	// is unstable sorted.
+	//
+	// All that said, this has been the behaviour for a long, long time, and I do not want to make other behavioural changes
+	// in what is mostly a refactor.
+	refsToLookup := []string{"HEAD"}
+	for _, remote := range remotes {
+		refsToLookup = append(refsToLookup, fmt.Sprintf("refs/remotes/%s/%s", remote.Name, currentBranch))
+	}
+
+	// Ignoring the error in this case is allowed because we may get refs and an error (see: --verify flag above).
+	// Ideally there would be a typed error to allow us to distinguish between an execution error and some refs
+	// not existing. However, this is too much to take on in an already large refactor.
+	refs, _ := opts.GitClient.ShowRefs(context.Background(), refsToLookup)
+	if len(refs) > 1 {
+		headRef := refs[0]
+		var firstMatchingRef o.Option[git.RemoteTrackingRef]
+		// Loop over all the refs, trying to find one that matches the SHA of HEAD.
+		for _, r := range refs[1:] {
+			if r.Hash == headRef.Hash {
+				remoteTrackingRef, err := git.ParseRemoteTrackingRef(r.Name)
+				if err != nil {
+					return nil, err
+				}
+
+				firstMatchingRef = o.Some(remoteTrackingRef)
+				break
+			}
 		}
 
-		if len(pushableRepos) == 0 {
-			pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
+		// If we found a matching ref, then we don't need to push.
+		if ref, present := firstMatchingRef.Value(); present {
+			remote, err := remotes.FindByName(ref.Remote)
 			if err != nil {
 				return nil, err
 			}
-		}
-
-		currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
-		if err != nil {
-			return nil, err
-		}
 
-		hasOwnFork := false
-		var pushOptions []string
-		for _, r := range pushableRepos {
-			pushOptions = append(pushOptions, ghrepo.FullName(r))
-			if r.RepoOwner() == currentLogin {
-				hasOwnFork = true
+			qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(ref.Branch)
+			if baseRepo.RepoOwner() != remote.RepoOwner() {
+				qualifiedHeadRef = shared.NewQualifiedHeadRef(remote.RepoOwner(), ref.Branch)
 			}
-		}
 
-		if !hasOwnFork {
-			pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
+			return newCreateContext(skipPushRefs{
+				qualifiedHeadRef: qualifiedHeadRef,
+				baseRefs:         baseRefs,
+			}), nil
 		}
-		pushOptions = append(pushOptions, "Skip pushing the branch")
-		pushOptions = append(pushOptions, "Cancel")
+	}
+
+	// If we haven't got a repo by now, and we can't prompt then it's game over.
+	if !opts.IO.CanPrompt() {
+		fmt.Fprintln(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
+		return nil, cmdutil.SilentError
+	}
+
+	// Otherwise, hooray, prompting!
 
-		selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", headBranch), "", pushOptions)
+	// First, we're going to look at our remotes and decide whether there are any repos we can push to.
+	pushableRepos, err := resolvedRemotes.HeadRepos()
+	if err != nil {
+		return nil, err
+	}
+
+	// If we couldn't find any pushable repos, then find forks of the base repo.
+	if len(pushableRepos) == 0 {
+		pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
 		if err != nil {
 			return nil, err
 		}
-
-		if selectedOption < len(pushableRepos) {
-			headRepo = pushableRepos[selectedOption]
-			if !ghrepo.IsSame(baseRepo, headRepo) {
-				headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
-			}
-		} else if pushOptions[selectedOption] == "Skip pushing the branch" {
-			isPushEnabled = false
-		} else if pushOptions[selectedOption] == "Cancel" {
-			return nil, cmdutil.CancelError
-		} else {
-			// "Create a fork of ..."
-			headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
-		}
 	}
 
-	if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
-		fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
-		return nil, cmdutil.SilentError
+	currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
+	if err != nil {
+		return nil, err
 	}
 
-	baseBranch := opts.BaseBranch
-	if baseBranch == "" {
-		baseBranch = headBranchConfig.MergeBase
-	}
-	if baseBranch == "" {
-		baseBranch = baseRepo.DefaultBranchRef.Name
+	hasOwnFork := false
+	var pushOptions []string
+	for _, r := range pushableRepos {
+		pushOptions = append(pushOptions, ghrepo.FullName(r))
+		if r.RepoOwner() == currentLogin {
+			hasOwnFork = true
+		}
 	}
-	if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
-		return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch)
+
+	if !hasOwnFork {
+		pushOptions = append(pushOptions, fmt.Sprintf("Create a fork of %s", ghrepo.FullName(baseRepo)))
 	}
+	pushOptions = append(pushOptions, "Skip pushing the branch")
+	pushOptions = append(pushOptions, "Cancel")
 
-	baseTrackingBranch := baseBranch
-	if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
-		baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
+	selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", currentBranch), "", pushOptions)
+	if err != nil {
+		return nil, err
 	}
 
-	return &CreateContext{
-		BaseRepo:           baseRepo,
-		HeadRepo:           headRepo,
-		BaseBranch:         baseBranch,
-		BaseTrackingBranch: baseTrackingBranch,
-		HeadBranch:         headBranch,
-		HeadBranchLabel:    headBranchLabel,
-		HeadRemote:         headRemote,
-		IsPushEnabled:      isPushEnabled,
-		RepoContext:        repoContext,
-		Client:             client,
-		GitClient:          gitClient,
-	}, nil
+	if selectedOption < len(pushableRepos) {
+		// A repository has been selected to push to.
+		return newCreateContext(pushableRefs{
+			headRepo:       pushableRepos[selectedOption],
+			headBranchName: currentBranch,
+			baseRefs:       baseRefs,
+		}), nil
+	} else if pushOptions[selectedOption] == "Skip pushing the branch" {
+		// We're going to skip pushing the branch altogether, meaning, use whatever SHA is already pushed.
+		// It's not exactly clear what repo the user expects to use here for the HEAD, and maybe we should
+		// make that clear in the UX somehow, but in the old implementation as far as I can tell, this
+		// always meant "use the base repo".
+		return newCreateContext(skipPushRefs{
+			qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner(currentBranch),
+			baseRefs:         baseRefs,
+		}), nil
+	} else if pushOptions[selectedOption] == "Cancel" {
+		return nil, cmdutil.CancelError
+	} else {
+		// A fork should be created.
+		return newCreateContext(forkableRefs{
+			qualifiedHeadRef: shared.NewQualifiedHeadRef(currentLogin, currentBranch),
+			baseRefs:         baseRefs,
+		}), nil
+	}
 }
 
 func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) {
@@ -805,15 +970,15 @@ func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) {
 	return remotes, nil
 }
 
-func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
+func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState, projectV1Support gh.ProjectsV1Support) error {
 	client := ctx.Client
 
 	params := map[string]interface{}{
 		"title":               state.Title,
 		"body":                state.Body,
 		"draft":               state.Draft,
-		"baseRefName":         ctx.BaseBranch,
-		"headRefName":         ctx.HeadBranchLabel,
+		"baseRefName":         ctx.PRRefs.BaseRef(),
+		"headRefName":         ctx.PRRefs.QualifiedHeadRef(),
 		"maintainerCanModify": opts.MaintainerCanModify,
 	}
 
@@ -821,7 +986,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
 		return errors.New("pull request title must not be blank")
 	}
 
-	err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state)
+	err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state, projectV1Support)
 	if err != nil {
 		return err
 	}
@@ -835,7 +1000,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
 	}
 
 	opts.IO.StartProgressIndicator()
-	pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params)
+	pr, err := api.CreatePullRequest(client, ctx.PRRefs.BaseRepo(), params)
 	opts.IO.StopProgressIndicator()
 	if pr != nil {
 		fmt.Fprintln(opts.IO.Out, pr.URL)
@@ -867,8 +1032,8 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s
 	if len(state.Milestones) != 0 {
 		fmt.Fprintf(w, "milestones:\t%v\n", strings.Join(state.Milestones, ", "))
 	}
-	if len(state.Projects) != 0 {
-		fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.Projects, ", "))
+	if len(state.ProjectTitles) != 0 {
+		fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.ProjectTitles, ", "))
 	}
 	fmt.Fprintf(w, "maintainerCanModify:\t%t\n", params["maintainerCanModify"])
 	fmt.Fprint(w, "body:\n")
@@ -879,37 +1044,37 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s
 }
 
 func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error {
-	iofmt := io.ColorScheme()
+	cs := io.ColorScheme()
 	out := io.Out
 
 	fmt.Fprint(out, "Would have created a Pull Request with:\n")
-	fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Title"), params["title"].(string))
-	fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("Draft"), params["draft"])
-	fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Base"), params["baseRefName"])
-	fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Head"), params["headRefName"])
+	fmt.Fprintf(out, "%s: %s\n", cs.Bold("Title"), params["title"].(string))
+	fmt.Fprintf(out, "%s: %t\n", cs.Bold("Draft"), params["draft"])
+	fmt.Fprintf(out, "%s: %s\n", cs.Bold("Base"), params["baseRefName"])
+	fmt.Fprintf(out, "%s: %s\n", cs.Bold("Head"), params["headRefName"])
 	if len(state.Labels) != 0 {
-		fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Labels"), strings.Join(state.Labels, ", "))
+		fmt.Fprintf(out, "%s: %s\n", cs.Bold("Labels"), strings.Join(state.Labels, ", "))
 	}
 	if len(state.Reviewers) != 0 {
-		fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Reviewers"), strings.Join(state.Reviewers, ", "))
+		fmt.Fprintf(out, "%s: %s\n", cs.Bold("Reviewers"), strings.Join(state.Reviewers, ", "))
 	}
 	if len(state.Assignees) != 0 {
-		fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Assignees"), strings.Join(state.Assignees, ", "))
+		fmt.Fprintf(out, "%s: %s\n", cs.Bold("Assignees"), strings.Join(state.Assignees, ", "))
 	}
 	if len(state.Milestones) != 0 {
-		fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Milestones"), strings.Join(state.Milestones, ", "))
+		fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", "))
 	}
-	if len(state.Projects) != 0 {
-		fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Projects"), strings.Join(state.Projects, ", "))
+	if len(state.ProjectTitles) != 0 {
+		fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.ProjectTitles, ", "))
 	}
-	fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("MaintainerCanModify"), params["maintainerCanModify"])
+	fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"])
 
-	fmt.Fprintf(out, "%s\n", iofmt.Bold("Body:"))
+	fmt.Fprintf(out, "%s\n", cs.Bold("Body:"))
 	// Body
 	var md string
 	var err error
 	if len(params["body"].(string)) == 0 {
-		md = fmt.Sprintf("%s\n", iofmt.Gray("No description provided"))
+		md = fmt.Sprintf("%s\n", cs.Muted("No description provided"))
 	} else {
 		md, err = markdown.Render(params["body"].(string),
 			markdown.WithTheme(io.TerminalTheme()),
@@ -931,38 +1096,43 @@ func previewPR(opts CreateOptions, openURL string) error {
 }
 
 func handlePush(opts CreateOptions, ctx CreateContext) error {
-	didForkRepo := false
-	headRepo := ctx.HeadRepo
-	headRemote := ctx.HeadRemote
-	client := ctx.Client
-	gitClient := ctx.GitClient
-
-	var err error
-	// if a head repository could not be determined so far, automatically create
-	// one by forking the base repository
-	if headRepo == nil && ctx.IsPushEnabled {
+	refs := ctx.PRRefs
+	forkableRefs, requiresFork := refs.(forkableRefs)
+	if requiresFork {
 		opts.IO.StartProgressIndicator()
-		headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false)
+		forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", false)
 		opts.IO.StopProgressIndicator()
 		if err != nil {
 			return fmt.Errorf("error forking repo: %w", err)
 		}
-		didForkRepo = true
+
+		refs = pushableRefs{
+			headRepo:       forkedRepo,
+			headBranchName: forkableRefs.qualifiedHeadRef.BranchName(),
+			baseRefs: baseRefs{
+				baseRepo:       forkableRefs.baseRepo,
+				baseBranchName: forkableRefs.baseBranchName,
+			},
+		}
 	}
 
-	if headRemote == nil && headRepo != nil {
-		headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo)
+	// We may have upcast to pushableRefs on fork, or we may have been passed an instance
+	// already. But if we haven't, then there's nothing more to do.
+	pushableRefs, ok := refs.(pushableRefs)
+	if !ok {
+		return nil
 	}
 
 	// There are two cases when an existing remote for the head repo will be
-	// missing:
+	// missing (and an error will be returned):
 	// 1. the head repo was just created by auto-forking;
 	// 2. an existing fork was discovered by querying the API.
 	// In either case, we want to add the head repo as a new git remote so we
 	// can push to it. We will try to add the head repo as the "origin" remote
 	// and fallback to the "fork" remote if it is unavailable. Also, if the
 	// base repo is the "origin" remote we will rename it "upstream".
-	if headRemote == nil && ctx.IsPushEnabled {
+	headRemote, _ := ctx.ResolvedRemotes.RemoteForRepo(pushableRefs.HeadRepo())
+	if headRemote == nil {
 		cfg, err := opts.Config()
 		if err != nil {
 			return err
@@ -973,8 +1143,8 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
 			return err
 		}
 
-		cloneProtocol := cfg.GitProtocol(headRepo.RepoHost()).Value
-		headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
+		cloneProtocol := cfg.GitProtocol(pushableRefs.HeadRepo().RepoHost()).Value
+		headRepoURL := ghrepo.FormatRemoteURL(pushableRefs.HeadRepo(), cloneProtocol)
 		gitClient := ctx.GitClient
 		origin, _ := remotes.FindByName("origin")
 		upstreamName := "upstream"
@@ -985,7 +1155,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
 			remoteName = "fork"
 		}
 
-		if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.BaseRepo) {
+		if origin != nil && upstream == nil && ghrepo.IsSame(origin, pushableRefs.BaseRepo()) {
 			renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName)
 			if err != nil {
 				return err
@@ -994,7 +1164,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
 				return fmt.Errorf("error renaming origin remote: %w", err)
 			}
 			remoteName = "origin"
-			fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), upstreamName)
+			fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(pushableRefs.BaseRepo()), upstreamName)
 		}
 
 		gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{})
@@ -1002,10 +1172,10 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
 			return fmt.Errorf("error adding remote: %w", err)
 		}
 
-		fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(headRepo), remoteName)
+		fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(pushableRefs.HeadRepo()), remoteName)
 
 		// Only mark `upstream` remote as default if `gh pr create` created the remote.
-		if didForkRepo {
+		if requiresFork {
 			err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base")
 			if err != nil {
 				return fmt.Errorf("error setting upstream as default: %w", err)
@@ -1013,52 +1183,46 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
 
 			if opts.IO.IsStdoutTTY() {
 				cs := opts.IO.ColorScheme()
-				fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(headRepo)))
+				fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(pushableRefs.HeadRepo())))
 			}
 		}
 
 		headRemote = &ghContext.Remote{
 			Remote: gitRemote,
-			Repo:   headRepo,
+			Repo:   pushableRefs.HeadRepo(),
 		}
 	}
 
 	// automatically push the branch if it hasn't been pushed anywhere yet
-	if ctx.IsPushEnabled {
-		pushBranch := func() error {
-			w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
-			defer w.Flush()
-			ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.HeadBranch)
-			bo := backoff.NewConstantBackOff(2 * time.Second)
-			ctx := context.Background()
-			return backoff.Retry(func() error {
-				if err := gitClient.Push(ctx, headRemote.Name, ref, git.WithStderr(w)); err != nil {
-					// Only retry if we have forked the repo else the push should succeed the first time.
-					if didForkRepo {
-						fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n")
-						return err
-					}
-					return backoff.Permanent(err)
+	pushBranch := func() error {
+		w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
+		defer w.Flush()
+		ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PRRefs.UnqualifiedHeadRef())
+		bo := backoff.NewConstantBackOff(2 * time.Second)
+		root := context.Background()
+		return backoff.Retry(func() error {
+			if err := ctx.GitClient.Push(root, headRemote.Name, ref, git.WithStderr(w)); err != nil {
+				// Only retry if we have forked the repo else the push should succeed the first time.
+				if requiresFork {
+					fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n")
+					return err
 				}
-				return nil
-			}, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx))
-		}
-
-		err := pushBranch()
-		if err != nil {
-			return err
-		}
+				return backoff.Permanent(err)
+			}
+			return nil
+		}, backoff.WithContext(backoff.WithMaxRetries(bo, 3), root))
 	}
 
-	return nil
+	return pushBranch()
 }
 
 func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) {
 	u := ghrepo.GenerateRepoURL(
-		ctx.BaseRepo,
+		ctx.PRRefs.BaseRepo(),
 		"compare/%s...%s?expand=1",
-		url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel))
-	url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state)
+		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, gh.ProjectsV1Supported)
 	if err != nil {
 		return "", err
 	}
diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go
index ee708f161..0b86d5e11 100644
--- a/pkg/cmd/pr/list/list.go
+++ b/pkg/cmd/pr/list/list.go
@@ -224,7 +224,7 @@ func listRun(opts *ListOptions) error {
 		if !isTTY {
 			table.AddField(shared.PrStateWithDraft(&pr))
 		}
-		table.AddTimeField(opts.Now(), pr.CreatedAt, cs.Gray)
+		table.AddTimeField(opts.Now(), pr.CreatedAt, cs.Muted)
 		table.EndRow()
 	}
 	err = table.Render()
diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go
index 25f81d973..cafa6ce8f 100644
--- a/pkg/cmd/pr/review/review.go
+++ b/pkg/cmd/pr/review/review.go
@@ -191,7 +191,7 @@ func reviewRun(opts *ReviewOptions) error {
 
 	switch reviewData.State {
 	case api.ReviewComment:
-		fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Gray("-"), ghrepo.FullName(baseRepo), pr.Number)
+		fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Muted("-"), ghrepo.FullName(baseRepo), pr.Number)
 	case api.ReviewApprove:
 		fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request %s#%d\n", cs.SuccessIcon(), ghrepo.FullName(baseRepo), pr.Number)
 	case api.ReviewRequestChanges:
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
index 46e3072e2..f909c7559 100644
--- a/pkg/cmd/pr/shared/commentable.go
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -92,26 +92,37 @@ func CommentableRun(opts *CommentableOptions) error {
 		return err
 	}
 	opts.Host = repo.RepoHost()
-	if opts.EditLast {
-		err := updateComment(commentable, opts)
-		if !errors.Is(err, errNoUserComments) {
+
+	// Create new comment, bail before complexities of updating the last comment
+	if !opts.EditLast {
+		return createComment(commentable, opts)
+	}
+
+	// Update the last comment, handling success or unexpected errors accordingly
+	err = updateComment(commentable, opts)
+	if err == nil {
+		return nil
+	}
+	if !errors.Is(err, errNoUserComments) {
+		return err
+	}
+
+	// Determine whether to create new comment, prompt user if interactive and missing option
+	if !opts.CreateIfNone && opts.Interactive {
+		opts.CreateIfNone, err = opts.ConfirmCreateIfNoneSurvey()
+		if err != nil {
 			return err
 		}
+	}
+	if !opts.CreateIfNone {
+		return errNoUserComments
+	}
 
-		if opts.Interactive {
-			if opts.CreateIfNone {
-				fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
-			} else {
-				ok, err := opts.ConfirmCreateIfNoneSurvey()
-				if err != nil {
-					return err
-				}
-				if !ok {
-					return errNoUserComments
-				}
-			}
-		}
+	// Create new comment because updating the last comment failed due to no user comments
+	if opts.Interactive {
+		fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
 	}
+
 	return createComment(commentable, opts)
 }
 
diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go
index a05108d7b..7c6e9154c 100644
--- a/pkg/cmd/pr/shared/comments.go
+++ b/pkg/cmd/pr/shared/comments.go
@@ -62,7 +62,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul
 	hiddenCount := totalCount - retrievedCount
 
 	if preview && hiddenCount > 0 {
-		fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment"))))
+		fmt.Fprint(&b, cs.Muted(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment"))))
 		fmt.Fprintf(&b, "\n\n\n")
 	}
 
@@ -79,7 +79,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul
 	}
 
 	if preview && hiddenCount > 0 {
-		fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation"))
+		fmt.Fprint(&b, cs.Muted("Use --comments to view the full conversation"))
 		fmt.Fprintln(&b)
 	}
 
@@ -122,7 +122,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
 	var md string
 	var err error
 	if comment.Content() == "" {
-		md = fmt.Sprintf("\n  %s\n\n", cs.Gray("No body provided"))
+		md = fmt.Sprintf("\n  %s\n\n", cs.Muted("No body provided"))
 	} else {
 		md, err = markdown.Render(comment.Content(),
 			markdown.WithTheme(io.TerminalTheme()),
@@ -135,7 +135,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
 
 	// Footer
 	if comment.Link() != "" {
-		fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link())
+		fmt.Fprintf(&b, cs.Muted("View the full review: %s\n\n"), comment.Link())
 	}
 
 	return b.String(), nil
diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go
index 02482951c..b4d83c719 100644
--- a/pkg/cmd/pr/shared/display.go
+++ b/pkg/cmd/pr/shared/display.go
@@ -60,7 +60,7 @@ func PrintHeader(io *iostreams.IOStreams, s string) {
 }
 
 func PrintMessage(io *iostreams.IOStreams, s string) {
-	fmt.Fprintln(io.Out, io.ColorScheme().Gray(s))
+	fmt.Fprintln(io.Out, io.ColorScheme().Muted(s))
 }
 
 func ListNoResults(repoName string, itemName string, hasFilters bool) error {
@@ -83,7 +83,7 @@ func ListHeader(repoName string, itemName string, matchCount int, totalMatchCoun
 }
 
 func PrCheckStatusSummaryWithColor(cs *iostreams.ColorScheme, checks api.PullRequestChecksStatus) string {
-	var summary = cs.Gray("No checks")
+	var summary = cs.Muted("No checks")
 	if checks.Total > 0 {
 		if checks.Failing > 0 {
 			if checks.Failing == checks.Total {
diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go
index cec3bfe8c..0bebb999a 100644
--- a/pkg/cmd/pr/shared/editable.go
+++ b/pkg/cmd/pr/shared/editable.go
@@ -381,7 +381,8 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable)
 		Reviewers:  editable.Reviewers.Edited,
 		Assignees:  editable.Assignees.Edited,
 		Labels:     editable.Labels.Edited,
-		Projects:   editable.Projects.Edited,
+		ProjectsV1: editable.Projects.Edited,
+		ProjectsV2: editable.Projects.Edited,
 		Milestones: editable.Milestone.Edited,
 	}
 	metadata, err := api.RepoMetadata(client, repo, input)
diff --git a/pkg/cmd/pr/shared/find_refs_resolution.go b/pkg/cmd/pr/shared/find_refs_resolution.go
new file mode 100644
index 000000000..e4e51bab8
--- /dev/null
+++ b/pkg/cmd/pr/shared/find_refs_resolution.go
@@ -0,0 +1,394 @@
+package shared
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strings"
+
+	ghContext "github.com/cli/cli/v2/context"
+	"github.com/cli/cli/v2/git"
+
+	"github.com/cli/cli/v2/internal/ghrepo"
+	o "github.com/cli/cli/v2/pkg/option"
+)
+
+// QualifiedHeadRef represents a git branch with an optional owner, used
+// for the head of a pull request. For example, within a single repository,
+// we would expect a PR to have a head ref of no owner, and a branch name.
+// However, for cross-repository pull requests, we would expect a head ref
+// with an owner and a branch name. In string form this is represented as
+// <owner>:<branch>. The GitHub API is able to interpret this format in order
+// to discover the correct fork repository.
+//
+// In other parts of the code, you may see this refered to as a HeadLabel.
+type QualifiedHeadRef struct {
+	owner      o.Option[string]
+	branchName string
+}
+
+// NewQualifiedHeadRef creates a QualifiedHeadRef. If the empty string is provided
+// for the owner, it will be treated as None.
+func NewQualifiedHeadRef(owner string, branchName string) QualifiedHeadRef {
+	return QualifiedHeadRef{
+		owner:      o.SomeIfNonZero(owner),
+		branchName: branchName,
+	}
+}
+
+func NewQualifiedHeadRefWithoutOwner(branchName string) QualifiedHeadRef {
+	return QualifiedHeadRef{
+		owner:      o.None[string](),
+		branchName: branchName,
+	}
+}
+
+// ParseQualifiedHeadRef takes strings of the form <owner>:<branch> or <branch>
+// and returns a QualifiedHeadRef. If the form <owner>:<branch> is used,
+// the owner is set to the value of <owner>, and the branch name is set to
+// the value of <branch>. If the form <branch> is used, the owner is set to
+// None, and the branch name is set to the value of <branch>.
+//
+// This does no further error checking about the validity of a ref, so
+// it is not safe to assume the ref is truly a valid ref, e.g. "my~bad:ref?"
+// is going to result in a nonsense result.
+func ParseQualifiedHeadRef(ref string) (QualifiedHeadRef, error) {
+	if !strings.Contains(ref, ":") {
+		return NewQualifiedHeadRefWithoutOwner(ref), nil
+	}
+
+	parts := strings.Split(ref, ":")
+	if len(parts) != 2 {
+		return QualifiedHeadRef{}, fmt.Errorf("invalid qualified head ref format '%s'", ref)
+	}
+
+	return NewQualifiedHeadRef(parts[0], parts[1]), nil
+}
+
+// A QualifiedHeadRef without an owner returns <branch>, while a QualifiedHeadRef
+// with an owner returns <owner>:<branch>.
+func (r QualifiedHeadRef) String() string {
+	if owner, present := r.owner.Value(); present {
+		return fmt.Sprintf("%s:%s", owner, r.branchName)
+	}
+	return r.branchName
+}
+
+func (r QualifiedHeadRef) BranchName() string {
+	return r.branchName
+}
+
+// PRFindRefs represents the necessary data to find a pull request from the API.
+type PRFindRefs struct {
+	qualifiedHeadRef QualifiedHeadRef
+
+	baseRepo ghrepo.Interface
+	// baseBranchName is an optional branch name, because it is not required for
+	// finding a pull request, only for disambiguation if multiple pull requests
+	// contain the same head ref.
+	baseBranchName o.Option[string]
+}
+
+// QualifiedHeadRef returns a stringified form of the head ref, varying depending
+// on whether the head ref is in the same repository as the base ref. If they are
+// the same repository, we return the branch name only. If they are different repositories,
+// we return the owner and branch name in the form <owner>:<branch>.
+func (r PRFindRefs) QualifiedHeadRef() string {
+	return r.qualifiedHeadRef.String()
+}
+
+func (r PRFindRefs) UnqualifiedHeadRef() string {
+	return r.qualifiedHeadRef.BranchName()
+}
+
+// Matches checks whether the provided baseBranchName and headRef match the refs.
+// It is used to determine whether Pull Requests returned from the API
+func (r PRFindRefs) Matches(baseBranchName, qualifiedHeadRef string) bool {
+	headMatches := qualifiedHeadRef == r.QualifiedHeadRef()
+	baseMatches := r.baseBranchName.IsNone() || baseBranchName == r.baseBranchName.Unwrap()
+	return headMatches && baseMatches
+}
+
+func (r PRFindRefs) BaseRepo() ghrepo.Interface {
+	return r.baseRepo
+}
+
+type RemoteNameToRepoFn func(remoteName string) (ghrepo.Interface, error)
+
+// PullRequestFindRefsResolver interrogates git configuration to try and determine
+// a head repository and a remote branch name, from a local branch name.
+type PullRequestFindRefsResolver struct {
+	GitConfigClient    GitConfigClient
+	RemoteNameToRepoFn RemoteNameToRepoFn
+}
+
+func NewPullRequestFindRefsResolver(gitConfigClient GitConfigClient, remotesFn func() (ghContext.Remotes, error)) PullRequestFindRefsResolver {
+	return PullRequestFindRefsResolver{
+		GitConfigClient:    gitConfigClient,
+		RemoteNameToRepoFn: newRemoteNameToRepoFn(remotesFn),
+	}
+}
+
+// ResolvePullRequests takes a base repository, a base branch name and a local branch name and uses the git configuration to
+// determine the head repository and remote branch name. If we were unable to determine this from git, we default the head
+// repository to the base repository.
+func (r *PullRequestFindRefsResolver) ResolvePullRequestRefs(baseRepo ghrepo.Interface, baseBranchName, localBranchName string) (PRFindRefs, error) {
+	if baseRepo == nil {
+		return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a base repository")
+	}
+
+	if localBranchName == "" {
+		return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a local branch name")
+	}
+
+	headPRRef, err := TryDetermineDefaultPRHead(r.GitConfigClient, remoteToRepoResolver{r.RemoteNameToRepoFn}, localBranchName)
+	if err != nil {
+		return PRFindRefs{}, err
+	}
+
+	// If the headRepo was resolved, we can just convert the response
+	// to refs and return it.
+	if headRepo, present := headPRRef.Repo.Value(); present {
+		qualifiedHeadRef := NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName)
+		if !ghrepo.IsSame(headRepo, baseRepo) {
+			qualifiedHeadRef = NewQualifiedHeadRef(headRepo.RepoOwner(), headPRRef.BranchName)
+		}
+
+		return PRFindRefs{
+			qualifiedHeadRef: qualifiedHeadRef,
+			baseRepo:         baseRepo,
+			baseBranchName:   o.SomeIfNonZero(baseBranchName),
+		}, nil
+	}
+
+	// If we didn't find a head repo, default to the base repo
+	return PRFindRefs{
+		qualifiedHeadRef: NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName),
+		baseRepo:         baseRepo,
+		baseBranchName:   o.SomeIfNonZero(baseBranchName),
+	}, nil
+}
+
+// DefaultPRHead is a neighbour to defaultPushTarget, but instead of holding
+// basic git remote information, it holds a resolved repository in `gh` terms.
+//
+// Since we may not be able to determine a default remote for a branch, this
+// is also true of the resolved repository.
+type DefaultPRHead struct {
+	Repo       o.Option[ghrepo.Interface]
+	BranchName string
+}
+
+// TryDetermineDefaultPRHead is a thin wrapper around determineDefaultPushTarget, which attempts to convert
+// a present remote into a resolved repository. If the remote is not present, we indicate that to the caller
+// by returning a None value for the repo.
+func TryDetermineDefaultPRHead(gitClient GitConfigClient, remoteToRepo remoteToRepoResolver, branch string) (DefaultPRHead, error) {
+	pushTarget, err := tryDetermineDefaultPushTarget(gitClient, branch)
+	if err != nil {
+		return DefaultPRHead{}, err
+	}
+
+	// If we have no remote, let the caller decide what to do by indicating that with a None.
+	if pushTarget.remote.IsNone() {
+		return DefaultPRHead{
+			Repo:       o.None[ghrepo.Interface](),
+			BranchName: pushTarget.branchName,
+		}, nil
+	}
+
+	repo, err := remoteToRepo.resolve(pushTarget.remote.Unwrap())
+	if err != nil {
+		return DefaultPRHead{}, err
+	}
+
+	return DefaultPRHead{
+		Repo:       o.Some(repo),
+		BranchName: pushTarget.branchName,
+	}, nil
+}
+
+// remote represents the value of the remote key in a branch's git configuration.
+// This value may be a name or a URL, both of which are strings, but are unfortunately
+// parsed by ReadBranchConfig into separate fields, allowing for illegal states to be
+// created by accident. This is an attempt to indicate that they are mutally exclusive.
+type remote interface{ sealedRemote() }
+
+type remoteName struct{ name string }
+
+func (rn remoteName) sealedRemote() {}
+
+type remoteURL struct{ url *url.URL }
+
+func (ru remoteURL) sealedRemote() {}
+
+// newRemoteNameToRepoFn takes a function that returns a list of remotes and
+// returns a function that takes a remote name and returns the corresponding
+// repository. It is a convenience function to call sites having to duplicate
+// the same logic.
+func newRemoteNameToRepoFn(remotesFn func() (ghContext.Remotes, error)) RemoteNameToRepoFn {
+	return func(remoteName string) (ghrepo.Interface, error) {
+		remotes, err := remotesFn()
+		if err != nil {
+			return nil, err
+		}
+		repo, err := remotes.FindByName(remoteName)
+		if err != nil {
+			return nil, err
+		}
+		return repo, nil
+	}
+}
+
+// remoteToRepoResolver provides a utility method to resolve a remote (either name or URL)
+// to a repo (ghrepo.Interface).
+type remoteToRepoResolver struct {
+	remoteNameToRepo RemoteNameToRepoFn
+}
+
+func NewRemoteToRepoResolver(remotesFn func() (ghContext.Remotes, error)) remoteToRepoResolver {
+	return remoteToRepoResolver{
+		remoteNameToRepo: newRemoteNameToRepoFn(remotesFn),
+	}
+}
+
+// resolve takes a remote and returns a repository representing it.
+func (r remoteToRepoResolver) resolve(remote remote) (ghrepo.Interface, error) {
+	switch v := remote.(type) {
+	case remoteName:
+		repo, err := r.remoteNameToRepo(v.name)
+		if err != nil {
+			return nil, fmt.Errorf("could not resolve remote %q: %w", v.name, err)
+		}
+		return repo, nil
+	case remoteURL:
+		repo, err := ghrepo.FromURL(v.url)
+		if err != nil {
+			return nil, fmt.Errorf("could not parse remote URL %q: %w", v.url, err)
+		}
+		return repo, nil
+	default:
+		return nil, fmt.Errorf("unsupported remote type %T, value: %v", v, remote)
+	}
+}
+
+// A defaultPushTarget represents the remote name or URL and a branch name
+// that we would expect a branch to be pushed to if `git push` were run with
+// no further arguments. This is the most likely place for the head of the PR
+// to be, but it's not guaranteed. The user may have pushed to another branch
+// directly via `git push <remote> <local>:<remote>` and not set up tracking information.
+// A branch name is always present.
+//
+// It's possible that we're unable to determine a remote, if the user had pushed directly
+// to a URL for example `git push <url> <branch>`, which is why it is optional. When present,
+// the remote may either be a name or a URL.
+type defaultPushTarget struct {
+	remote     o.Option[remote]
+	branchName string
+}
+
+// newDefaultPushTarget is a thin wrapper over defaultPushTarget to help with
+// generic type inference, to reduce verbosity in repeating the parametric type.
+func newDefaultPushTarget(remote remote, branchName string) defaultPushTarget {
+	return defaultPushTarget{
+		remote:     o.Some(remote),
+		branchName: branchName,
+	}
+}
+
+// tryDetermineDefaultPushTarget uses git configuration to make a best guess about where a branch
+// is pushed to, and where it would be pushed to if the user ran `git push` with no additional
+// arguments.
+//
+// Firstly, it attempts to resolve the @{push} ref, which is the most reliable method, as this
+// is what git uses to determine the remote tracking branch
+//
+// If this fails, we go through a series of steps to determine the remote:
+//
+// 1. check branch configuration for `branch.<name>.pushRemote = <name> | <url>`
+// 2. check remote configuration for `remote.pushDefault = <name>`
+// 3. check branch configuration for `branch.<name>.remote = <name> | <url>`
+//
+// If none of these are set, we indicate that we were unable to determine the
+// remote by returning a None value for the remote.
+//
+// The branch name is always set. The default configuration for push.default (current) indicates
+// that a git push should use the same remote branch name as the local branch name. If push.default
+// is set to upstream or tracking (deprecated form of upstream), then we use the branch name from the merge ref.
+func tryDetermineDefaultPushTarget(gitClient GitConfigClient, localBranchName string) (defaultPushTarget, error) {
+	// If @{push} resolves, then we have the remote tracking branch already, no problem.
+	if pushRevisionRef, err := gitClient.PushRevision(context.Background(), localBranchName); err == nil {
+		return newDefaultPushTarget(remoteName{pushRevisionRef.Remote}, pushRevisionRef.Branch), nil
+	}
+
+	// But it doesn't always resolve, so we can suppress the error and move on to other means
+	// of determination. We'll first look at branch and remote configuration to make a determination.
+	branchConfig, err := gitClient.ReadBranchConfig(context.Background(), localBranchName)
+	if err != nil {
+		return defaultPushTarget{}, err
+	}
+
+	pushDefault, err := gitClient.PushDefault(context.Background())
+	if err != nil {
+		return defaultPushTarget{}, err
+	}
+
+	// We assume the PR's branch name is the same as whatever was provided, unless the user has specified
+	// push.default = upstream or tracking, then we use the branch name from the merge ref if it exists. Otherwise, we fall back to the local branch name
+	remoteBranch := localBranchName
+	if pushDefault == git.PushDefaultUpstream || pushDefault == git.PushDefaultTracking {
+		mergeRef := strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
+		if mergeRef != "" {
+			remoteBranch = mergeRef
+		}
+	}
+
+	// To get the remote, we look to the git config. It comes from one of the following, in order of precedence:
+	// 1. branch.<name>.pushRemote (which may be a name or a URL)
+	// 2. remote.pushDefault (which is a remote name)
+	// 3. branch.<name>.remote (which may be a name or a URL)
+	if branchConfig.PushRemoteName != "" {
+		return newDefaultPushTarget(
+			remoteName{branchConfig.PushRemoteName},
+			remoteBranch,
+		), nil
+	}
+
+	if branchConfig.PushRemoteURL != nil {
+		return newDefaultPushTarget(
+			remoteURL{branchConfig.PushRemoteURL},
+			remoteBranch,
+		), nil
+	}
+
+	remotePushDefault, err := gitClient.RemotePushDefault(context.Background())
+	if err != nil {
+		return defaultPushTarget{}, err
+	}
+
+	if remotePushDefault != "" {
+		return newDefaultPushTarget(
+			remoteName{remotePushDefault},
+			remoteBranch,
+		), nil
+	}
+
+	if branchConfig.RemoteName != "" {
+		return newDefaultPushTarget(
+			remoteName{branchConfig.RemoteName},
+			remoteBranch,
+		), nil
+	}
+
+	if branchConfig.RemoteURL != nil {
+		return newDefaultPushTarget(
+			remoteURL{branchConfig.RemoteURL},
+			remoteBranch,
+		), nil
+	}
+
+	// If we couldn't find the remote, we'll indicate that to the caller via None.
+	return defaultPushTarget{
+		remote:     o.None[remote](),
+		branchName: remoteBranch,
+	}, nil
+}
diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go
index a54528527..e6bb7d66a 100644
--- a/pkg/cmd/pr/shared/finder.go
+++ b/pkg/cmd/pr/shared/finder.go
@@ -13,11 +13,12 @@ import (
 	"time"
 
 	"github.com/cli/cli/v2/api"
-	remotes "github.com/cli/cli/v2/context"
+	ghContext "github.com/cli/cli/v2/context"
 	"github.com/cli/cli/v2/git"
 	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/cmdutil"
+	o "github.com/cli/cli/v2/pkg/option"
 	"github.com/cli/cli/v2/pkg/set"
 	"github.com/shurcooL/githubv4"
 	"golang.org/x/sync/errgroup"
@@ -32,16 +33,20 @@ type progressIndicator interface {
 	StopProgressIndicator()
 }
 
+type GitConfigClient interface {
+	ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error)
+	PushDefault(ctx context.Context) (git.PushDefault, error)
+	RemotePushDefault(ctx context.Context) (string, error)
+	PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error)
+}
+
 type finder struct {
-	baseRepoFn        func() (ghrepo.Interface, error)
-	branchFn          func() (string, error)
-	remotesFn         func() (remotes.Remotes, error)
-	httpClient        func() (*http.Client, error)
-	pushDefault       func() (string, error)
-	remotePushDefault func() (string, error)
-	parsePushRevision func(string) (string, error)
-	branchConfig      func(string) (git.BranchConfig, error)
-	progress          progressIndicator
+	baseRepoFn      func() (ghrepo.Interface, error)
+	branchFn        func() (string, error)
+	httpClient      func() (*http.Client, error)
+	remotesFn       func() (ghContext.Remotes, error)
+	gitConfigClient GitConfigClient
+	progress        progressIndicator
 
 	baseRefRepo ghrepo.Interface
 	prNumber    int
@@ -56,23 +61,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder {
 	}
 
 	return &finder{
-		baseRepoFn: factory.BaseRepo,
-		branchFn:   factory.Branch,
-		remotesFn:  factory.Remotes,
-		httpClient: factory.HttpClient,
-		pushDefault: func() (string, error) {
-			return factory.GitClient.PushDefault(context.Background())
-		},
-		remotePushDefault: func() (string, error) {
-			return factory.GitClient.RemotePushDefault(context.Background())
-		},
-		parsePushRevision: func(branch string) (string, error) {
-			return factory.GitClient.ParsePushRevision(context.Background(), branch)
-		},
-		progress: factory.IOStreams,
-		branchConfig: func(s string) (git.BranchConfig, error) {
-			return factory.GitClient.ReadBranchConfig(context.Background(), s)
-		},
+		baseRepoFn:      factory.BaseRepo,
+		branchFn:        factory.Branch,
+		httpClient:      factory.HttpClient,
+		gitConfigClient: factory.GitClient,
+		remotesFn:       factory.Remotes,
+		progress:        factory.IOStreams,
 	}
 }
 
@@ -97,28 +91,6 @@ type FindOptions struct {
 	States []string
 }
 
-// TODO: Does this also need the BaseBranchName?
-// PR's are represented by the following:
-// baseRef -----PR-----> headRef
-//
-// A ref is described as "remoteName/branchName", so
-// baseRepoName/baseBranchName -----PR-----> headRepoName/headBranchName
-type PullRequestRefs struct {
-	BranchName string
-	HeadRepo   ghrepo.Interface
-	BaseRepo   ghrepo.Interface
-}
-
-// GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is
-// either just the branch name or, if the PR is originating from a fork, the fork owner
-// and the branch name, like <owner>:<branch>.
-func (s *PullRequestRefs) GetPRHeadLabel() string {
-	if ghrepo.IsSame(s.HeadRepo, s.BaseRepo) {
-		return s.BranchName
-	}
-	return fmt.Sprintf("%s:%s", s.HeadRepo.RepoOwner(), s.BranchName)
-}
-
 func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) {
 	// If we have a URL, we don't need git stuff
 	if len(opts.Fields) == 0 {
@@ -138,7 +110,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 		f.baseRefRepo = repo
 	}
 
-	var prRefs PullRequestRefs
+	var prRefs PRFindRefs
 	if opts.Selector == "" {
 		// You must be in a git repo for this case to work
 		currentBranchName, err := f.branchFn()
@@ -148,7 +120,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 		f.branchName = currentBranchName
 
 		// Get the branch config for the current branchName
-		branchConfig, err := f.branchConfig(f.branchName)
+		branchConfig, err := f.gitConfigClient.ReadBranchConfig(context.Background(), f.branchName)
 		if err != nil {
 			return nil, nil, err
 		}
@@ -162,30 +134,19 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 
 		// Determine the PullRequestRefs from config
 		if f.prNumber == 0 {
-			rems, err := f.remotesFn()
-			if err != nil {
-				return nil, nil, err
-			}
-
-			// Suppressing these errors as we have other means of computing the PullRequestRefs when these fail.
-			parsedPushRevision, _ := f.parsePushRevision(f.branchName)
-
-			pushDefault, err := f.pushDefault()
-			if err != nil {
-				return nil, nil, err
-			}
-
-			remotePushDefault, err := f.remotePushDefault()
-			if err != nil {
-				return nil, nil, err
-			}
-
-			prRefs, err = ParsePRRefs(f.branchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, f.baseRefRepo, rems)
+			prRefsResolver := NewPullRequestFindRefsResolver(
+				// We requested the branch config already, so let's cache that
+				CachedBranchConfigGitConfigClient{
+					CachedBranchConfig: branchConfig,
+					GitConfigClient:    f.gitConfigClient,
+				},
+				f.remotesFn,
+			)
+			prRefs, err = prRefsResolver.ResolvePullRequestRefs(f.baseRefRepo, opts.BaseBranch, f.branchName)
 			if err != nil {
 				return nil, nil, err
 			}
 		}
-
 	} else if f.prNumber == 0 {
 		// You gave me a selector but I couldn't find a PR number (it wasn't a URL)
 
@@ -200,11 +161,17 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 			f.prNumber = prNumber
 		} else {
 			f.branchName = opts.Selector
-			// We don't expect an error here because parsedPushRevision is empty
-			prRefs, err = ParsePRRefs(f.branchName, git.BranchConfig{}, "", "", "", f.baseRefRepo, remotes.Remotes{})
+
+			qualifiedHeadRef, err := ParseQualifiedHeadRef(f.branchName)
 			if err != nil {
 				return nil, nil, err
 			}
+
+			prRefs = PRFindRefs{
+				qualifiedHeadRef: qualifiedHeadRef,
+				baseRepo:         f.baseRefRepo,
+				baseBranchName:   o.SomeIfNonZero(opts.BaseBranch),
+			}
 		}
 	}
 
@@ -255,7 +222,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 			return pr, f.baseRefRepo, err
 		}
 	} else {
-		pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice())
+		pr, err = findForRefs(httpClient, prRefs, opts.States, fields.ToSlice())
 		if err != nil {
 			return pr, f.baseRefRepo, err
 		}
@@ -272,6 +239,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
 			return preloadPrComments(httpClient, f.baseRefRepo, pr)
 		})
 	}
+	if fields.Contains("closingIssuesReferences") {
+		g.Go(func() error {
+			return preloadPrClosingIssuesReferences(httpClient, f.baseRefRepo, pr)
+		})
+	}
 	if fields.Contains("statusCheckRollup") {
 		g.Go(func() error {
 			return preloadPrChecks(httpClient, f.baseRefRepo, pr)
@@ -317,72 +289,6 @@ func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) {
 	return repo, prNumber, nil
 }
 
-func ParsePRRefs(currentBranchName string, branchConfig git.BranchConfig, parsedPushRevision string, pushDefault string, remotePushDefault string, baseRefRepo ghrepo.Interface, rems remotes.Remotes) (PullRequestRefs, error) {
-	prRefs := PullRequestRefs{
-		BaseRepo: baseRefRepo,
-	}
-
-	// If @{push} resolves, then we have all the information we need to determine the head repo
-	// and branch name. It is of the form <remote>/<branch>.
-	if parsedPushRevision != "" {
-		for _, r := range rems {
-			// Find the remote who's name matches the push <remote> prefix
-			if strings.HasPrefix(parsedPushRevision, r.Name+"/") {
-				prRefs.BranchName = strings.TrimPrefix(parsedPushRevision, r.Name+"/")
-				prRefs.HeadRepo = r.Repo
-				return prRefs, nil
-			}
-		}
-
-		remoteNames := make([]string, len(rems))
-		for i, r := range rems {
-			remoteNames[i] = r.Name
-		}
-		return PullRequestRefs{}, fmt.Errorf("no remote for %q found in %q", parsedPushRevision, strings.Join(remoteNames, ", "))
-	}
-
-	// We assume the PR's branch name is the same as whatever f.BranchFn() returned earlier
-	// unless the user has specified push.default = upstream or tracking, then we use the
-	// branch name from the merge ref.
-	prRefs.BranchName = currentBranchName
-	if pushDefault == "upstream" || pushDefault == "tracking" {
-		prRefs.BranchName = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
-	}
-
-	// To get the HeadRepo, we look to the git config. The HeadRepo comes from one of the following, in order of precedence:
-	// 1. branch.<name>.pushRemote
-	// 2. remote.pushDefault
-	// 3. branch.<name>.remote
-	if branchConfig.PushRemoteName != "" {
-		if r, err := rems.FindByName(branchConfig.PushRemoteName); err == nil {
-			prRefs.HeadRepo = r.Repo
-		}
-	} else if branchConfig.PushRemoteURL != nil {
-		if r, err := ghrepo.FromURL(branchConfig.PushRemoteURL); err == nil {
-			prRefs.HeadRepo = r
-		}
-	} else if remotePushDefault != "" {
-		if r, err := rems.FindByName(remotePushDefault); err == nil {
-			prRefs.HeadRepo = r.Repo
-		}
-	} else if branchConfig.RemoteName != "" {
-		if r, err := rems.FindByName(branchConfig.RemoteName); err == nil {
-			prRefs.HeadRepo = r.Repo
-		}
-	} else if branchConfig.RemoteURL != nil {
-		if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
-			prRefs.HeadRepo = r
-		}
-	}
-
-	// The PR merges from a branch in the same repo as the base branch (usually the default branch)
-	if prRefs.HeadRepo == nil {
-		prRefs.HeadRepo = baseRefRepo
-	}
-
-	return prRefs, nil
-}
-
 func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) {
 	type response struct {
 		Repository struct {
@@ -413,7 +319,7 @@ func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fi
 	return &resp.Repository.PullRequest, nil
 }
 
-func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranchWithOwnerIfFork string, stateFilters, fields []string) (*api.PullRequest, error) {
+func findForRefs(httpClient *http.Client, prRefs PRFindRefs, stateFilters, fields []string) (*api.PullRequest, error) {
 	type response struct {
 		Repository struct {
 			PullRequests struct {
@@ -440,21 +346,16 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
 		}
 	}`, api.PullRequestGraphQL(fieldSet.ToSlice()))
 
-	branchWithoutOwner := headBranchWithOwnerIfFork
-	if idx := strings.Index(headBranchWithOwnerIfFork, ":"); idx >= 0 {
-		branchWithoutOwner = headBranchWithOwnerIfFork[idx+1:]
-	}
-
 	variables := map[string]interface{}{
-		"owner":       repo.RepoOwner(),
-		"repo":        repo.RepoName(),
-		"headRefName": branchWithoutOwner,
+		"owner":       prRefs.BaseRepo().RepoOwner(),
+		"repo":        prRefs.BaseRepo().RepoName(),
+		"headRefName": prRefs.UnqualifiedHeadRef(),
 		"states":      stateFilters,
 	}
 
 	var resp response
 	client := api.NewClientFromHTTP(httpClient)
-	err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
+	err := client.GraphQL(prRefs.BaseRepo().RepoHost(), query, variables, &resp)
 	if err != nil {
 		return nil, err
 	}
@@ -465,17 +366,15 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
 	})
 
 	for _, pr := range prs {
-		headBranchMatches := pr.HeadLabel() == headBranchWithOwnerIfFork
-		baseBranchEmptyOrMatches := baseBranch == "" || pr.BaseRefName == baseBranch
 		// When the head is the default branch, it doesn't really make sense to show merged or closed PRs.
 		// https://github.com/cli/cli/issues/4263
-		isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranchWithOwnerIfFork
-		if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault {
+		isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != prRefs.QualifiedHeadRef()
+		if prRefs.Matches(pr.BaseRefName, pr.HeadLabel()) && isNotClosedOrMergedWhenHeadIsDefault {
 			return &pr, nil
 		}
 	}
 
-	return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranchWithOwnerIfFork)}
+	return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", prRefs.QualifiedHeadRef())}
 }
 
 func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
@@ -558,6 +457,45 @@ func preloadPrComments(client *http.Client, repo ghrepo.Interface, pr *api.PullR
 	return nil
 }
 
+func preloadPrClosingIssuesReferences(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
+	if !pr.ClosingIssuesReferences.PageInfo.HasNextPage {
+		return nil
+	}
+
+	type response struct {
+		Node struct {
+			PullRequest struct {
+				ClosingIssuesReferences api.ClosingIssuesReferences `graphql:"closingIssuesReferences(first: 100, after: $endCursor)"`
+			} `graphql:"...on PullRequest"`
+		} `graphql:"node(id: $id)"`
+	}
+
+	variables := map[string]interface{}{
+		"id":        githubv4.ID(pr.ID),
+		"endCursor": githubv4.String(pr.ClosingIssuesReferences.PageInfo.EndCursor),
+	}
+
+	gql := api.NewClientFromHTTP(client)
+
+	for {
+		var query response
+		err := gql.Query(repo.RepoHost(), "closingIssuesReferences", &query, variables)
+		if err != nil {
+			return err
+		}
+
+		pr.ClosingIssuesReferences.Nodes = append(pr.ClosingIssuesReferences.Nodes, query.Node.PullRequest.ClosingIssuesReferences.Nodes...)
+
+		if !query.Node.PullRequest.ClosingIssuesReferences.PageInfo.HasNextPage {
+			break
+		}
+		variables["endCursor"] = githubv4.String(query.Node.PullRequest.ClosingIssuesReferences.PageInfo.EndCursor)
+	}
+
+	pr.ClosingIssuesReferences.PageInfo.HasNextPage = false
+	return nil
+}
+
 func preloadPrChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
 	if len(pr.StatusCheckRollup.Nodes) == 0 {
 		return nil
diff --git a/pkg/cmd/pr/shared/git_cached_config_client.go b/pkg/cmd/pr/shared/git_cached_config_client.go
new file mode 100644
index 000000000..aea25abee
--- /dev/null
+++ b/pkg/cmd/pr/shared/git_cached_config_client.go
@@ -0,0 +1,18 @@
+package shared
+
+import (
+	"context"
+
+	"github.com/cli/cli/v2/git"
+)
+
+var _ GitConfigClient = &CachedBranchConfigGitConfigClient{}
+
+type CachedBranchConfigGitConfigClient struct {
+	CachedBranchConfig git.BranchConfig
+	GitConfigClient
+}
+
+func (c CachedBranchConfigGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) {
+	return c.CachedBranchConfig, nil
+}
diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go
index 128c51068..4f36a80aa 100644
--- a/pkg/cmd/pr/shared/params.go
+++ b/pkg/cmd/pr/shared/params.go
@@ -6,12 +6,13 @@ import (
 	"strings"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/search"
 	"github.com/google/shlex"
 )
 
-func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState) (string, error) {
+func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) {
 	u, err := url.Parse(baseURL)
 	if err != nil {
 		return "", err
@@ -34,8 +35,8 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba
 	if len(state.Labels) > 0 {
 		q.Set("labels", strings.Join(state.Labels, ","))
 	}
-	if len(state.Projects) > 0 {
-		projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects)
+	if len(state.ProjectTitles) > 0 {
+		projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support)
 		if err != nil {
 			return "", fmt.Errorf("could not add to project: %w", err)
 		}
@@ -56,7 +57,7 @@ func ValidURL(urlStr string) bool {
 
 // Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able
 // to resolve all object listed in tb to GraphQL IDs.
-func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error {
+func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error {
 	resolveInput := api.RepoResolveInput{}
 
 	if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) {
@@ -71,8 +72,12 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada
 		resolveInput.Labels = tb.Labels
 	}
 
-	if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) {
-		resolveInput.Projects = tb.Projects
+	if len(tb.ProjectTitles) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) {
+		if projectV1Support == gh.ProjectsV1Supported {
+			resolveInput.ProjectsV1 = true
+		}
+
+		resolveInput.ProjectsV2 = true
 	}
 
 	if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) {
@@ -93,12 +98,12 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada
 	return nil
 }
 
-func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error {
+func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error {
 	if !tb.HasMetadata() {
 		return nil
 	}
 
-	if err := fillMetadata(client, baseRepo, tb); err != nil {
+	if err := fillMetadata(client, baseRepo, tb, projectV1Support); err != nil {
 		return err
 	}
 
@@ -114,7 +119,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
 	}
 	params["labelIds"] = labelIDs
 
-	projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
+	projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.ProjectTitles)
 	if err != nil {
 		return fmt.Errorf("could not add to project: %w", err)
 	}
diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go
index 143021cb6..7e7da436d 100644
--- a/pkg/cmd/pr/shared/state.go
+++ b/pkg/cmd/pr/shared/state.go
@@ -25,12 +25,12 @@ type IssueMetadataState struct {
 
 	Template string
 
-	Metadata   []string
-	Reviewers  []string
-	Assignees  []string
-	Labels     []string
-	Projects   []string
-	Milestones []string
+	Metadata      []string
+	Reviewers     []string
+	Assignees     []string
+	Labels        []string
+	ProjectTitles []string
+	Milestones    []string
 
 	MetadataResult *api.RepoMetadataResult
 
@@ -49,7 +49,7 @@ func (tb *IssueMetadataState) HasMetadata() bool {
 	return len(tb.Reviewers) > 0 ||
 		len(tb.Assignees) > 0 ||
 		len(tb.Labels) > 0 ||
-		len(tb.Projects) > 0 ||
+		len(tb.ProjectTitles) > 0 ||
 		len(tb.Milestones) > 0
 }
 
diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go
index ce38535d9..bf4476ca1 100644
--- a/pkg/cmd/pr/shared/survey.go
+++ b/pkg/cmd/pr/shared/survey.go
@@ -151,7 +151,7 @@ type RepoMetadataFetcher interface {
 	RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
 }
 
-func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error {
+func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error {
 	isChosen := func(m string) bool {
 		for _, c := range state.Metadata {
 			if m == c {
@@ -181,7 +181,8 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
 		Reviewers:  isChosen("Reviewers"),
 		Assignees:  isChosen("Assignees"),
 		Labels:     isChosen("Labels"),
-		Projects:   isChosen("Projects"),
+		ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported,
+		ProjectsV2: isChosen("Projects"),
 		Milestones: isChosen("Milestone"),
 	}
 	metadataResult, err := fetcher.RepoMetadataFetch(metadataInput)
@@ -267,7 +268,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
 	}
 	if isChosen("Projects") {
 		if len(projects) > 0 {
-			selected, err := p.MultiSelect("Projects", state.Projects, projects)
+			selected, err := p.MultiSelect("Projects", state.ProjectTitles, projects)
 			if err != nil {
 				return err
 			}
@@ -316,7 +317,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
 		state.Labels = values.Labels
 	}
 	if isChosen("Projects") {
-		state.Projects = values.Projects
+		state.ProjectTitles = values.Projects
 	}
 	if isChosen("Milestone") {
 		if values.Milestone != "" && values.Milestone != noMilestone {
diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go
index b7b390bf2..60202594f 100644
--- a/pkg/cmd/pr/status/status.go
+++ b/pkg/cmd/pr/status/status.go
@@ -102,43 +102,34 @@ func statusRun(opts *StatusOptions) error {
 			return fmt.Errorf("could not query for pull request for current branch: %w", err)
 		}
 
-		branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName)
-		if err != nil {
-			return err
-		}
-		// Determine if the branch is configured to merge to a special PR ref
-		prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
-		if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
-			currentPRNumber, _ = strconv.Atoi(m[1])
-		}
-
-		if currentPRNumber == 0 {
-			remotes, err := opts.Remotes()
+		if !errors.Is(err, git.ErrNotOnAnyBranch) {
+			branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName)
 			if err != nil {
 				return err
 			}
-			// Suppressing these errors as we have other means of computing the PullRequestRefs when these fail.
-			parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, currentBranchName)
-
-			remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx)
-			if err != nil {
-				return err
+			// Determine if the branch is configured to merge to a special PR ref
+			prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
+			if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
+				currentPRNumber, _ = strconv.Atoi(m[1])
 			}
 
-			pushDefault, err := opts.GitClient.PushDefault(ctx)
-			if err != nil {
-				return err
-			}
+			if currentPRNumber == 0 {
+				prRefsResolver := shared.NewPullRequestFindRefsResolver(
+					// We requested the branch config already, so let's cache that
+					shared.CachedBranchConfigGitConfigClient{
+						CachedBranchConfig: branchConfig,
+						GitConfigClient:    opts.GitClient,
+					},
+					opts.Remotes,
+				)
+
+				prRefs, err := prRefsResolver.ResolvePullRequestRefs(baseRefRepo, "", currentBranchName)
+				if err != nil {
+					return err
+				}
 
-			prRefs, err := shared.ParsePRRefs(currentBranchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRefRepo, remotes)
-			if err != nil {
-				return err
+				currentHeadRefBranchName = prRefs.QualifiedHeadRef()
 			}
-			currentHeadRefBranchName = prRefs.BranchName
-		}
-
-		if err != nil {
-			return fmt.Errorf("could not query for pull request for current branch: %w", err)
 		}
 	}
 
@@ -316,6 +307,6 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
 	}
 	remaining := totalCount - len(prs)
 	if remaining > 0 {
-		fmt.Fprintf(w, cs.Gray("  And %d more\n"), remaining)
+		fmt.Fprintln(w, cs.Mutedf("  And %d more", remaining))
 	}
 }
diff --git a/pkg/cmd/pr/update-branch/update_branch.go b/pkg/cmd/pr/update-branch/update_branch.go
index 023fdae50..ca3b2c910 100644
--- a/pkg/cmd/pr/update-branch/update_branch.go
+++ b/pkg/cmd/pr/update-branch/update_branch.go
@@ -43,7 +43,7 @@ func NewCmdUpdateBranch(f *cmdutil.Factory, runF func(*UpdateBranchOptions) erro
 			Without an argument, the pull request that belongs to the current branch is selected.
 
 			The default behavior is to update with a merge commit (i.e., merging the base branch
-			into the the PR's branch). To reconcile the changes with rebasing on top of the base
+			into the PR's branch). To reconcile the changes with rebasing on top of the base
 			branch, the %[1]s--rebase%[1]s option should be provided.
 		`, "`"),
 		Example: heredoc.Doc(`
diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go
index ccad4fa77..997f74d87 100644
--- a/pkg/cmd/pr/view/view.go
+++ b/pkg/cmd/pr/view/view.go
@@ -260,7 +260,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P
 	var md string
 	var err error
 	if pr.Body == "" {
-		md = fmt.Sprintf("\n  %s\n\n", cs.Gray("No description provided"))
+		md = fmt.Sprintf("\n  %s\n\n", cs.Muted("No description provided"))
 	} else {
 		md, err = markdown.Render(pr.Body,
 			markdown.WithTheme(opts.IO.TerminalTheme()),
@@ -282,7 +282,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P
 	}
 
 	// Footer
-	fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL)
+	fmt.Fprintf(out, cs.Muted("View this pull request on GitHub: %s\n"), pr.URL)
 
 	return nil
 }
@@ -423,7 +423,7 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
 
 	labelNames := make([]string, 0, len(pr.Labels.Nodes))
 	for _, label := range pr.Labels.Nodes {
-		labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
+		labelNames = append(labelNames, cs.Label(label.Color, label.Name))
 	}
 
 	list := strings.Join(labelNames, ", ")
diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go
index 3e63465dd..46aa58451 100644
--- a/pkg/cmd/project/shared/queries/queries.go
+++ b/pkg/cmd/project/shared/queries/queries.go
@@ -7,9 +7,7 @@ import (
 	"net/url"
 	"regexp"
 	"strings"
-	"time"
 
-	"github.com/briandowns/spinner"
 	"github.com/cli/cli/v2/api"
 	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -24,8 +22,8 @@ func NewClient(httpClient *http.Client, hostname string, ios *iostreams.IOStream
 	}
 	return &Client{
 		apiClient: apiClient,
-		spinner:   ios.IsStdoutTTY() && ios.IsStderrTTY(),
-		prompter:  prompter.New("", ios.In, ios.Out, ios.ErrOut),
+		io:        ios,
+		prompter:  prompter.New("", ios),
 	}
 }
 
@@ -44,9 +42,10 @@ func NewTestClient(opts ...TestClientOpt) *Client {
 		hostname: "github.com",
 		Client:   api.NewClientFromHTTP(http.DefaultClient),
 	}
+	io, _, _, _ := iostreams.Test()
 	c := &Client{
 		apiClient: apiClient,
-		spinner:   false,
+		io:        io,
 		prompter:  nil,
 	}
 
@@ -80,7 +79,7 @@ type graphqlClient interface {
 
 type Client struct {
 	apiClient graphqlClient
-	spinner   bool
+	io        *iostreams.IOStreams
 	prompter  iprompter
 }
 
@@ -89,19 +88,12 @@ const (
 	LimitMax     = 100 // https://docs.github.com/en/graphql/overview/resource-limitations#node-limit
 )
 
-// doQuery wraps API calls with a visual spinner
-func (c *Client) doQuery(name string, query interface{}, variables map[string]interface{}) error {
-	var sp *spinner.Spinner
-	if c.spinner {
-		// https://github.com/briandowns/spinner#available-character-sets
-		dotStyle := spinner.CharSets[11]
-		sp = spinner.New(dotStyle, 120*time.Millisecond, spinner.WithColor("fgCyan"))
-		sp.Start()
-	}
+// doQueryWithProgressIndicator wraps API calls with a progress indicator.
+// The query name is used in the progress indicator label.
+func (c *Client) doQueryWithProgressIndicator(name string, query interface{}, variables map[string]interface{}) error {
+	c.io.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching %s", name))
+	defer c.io.StopProgressIndicator()
 	err := c.apiClient.Query(name, query, variables)
-	if sp != nil {
-		sp.Stop()
-	}
 	return handleError(err)
 }
 
@@ -552,7 +544,7 @@ func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, erro
 		query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries
 		queryName = "ViewerProjectWithItems"
 	}
-	err := c.doQuery(queryName, query, variables)
+	err := c.doQueryWithProgressIndicator(queryName, query, variables)
 	if err != nil {
 		return project, err
 	}
@@ -706,7 +698,7 @@ func paginateAttributes[N projectAttribute](c *Client, p pager[N], variables map
 
 		// set the cursor to the end of the last page
 		variables[afterKey] = (*githubv4.String)(&cursor)
-		err := c.doQuery(queryName, p, variables)
+		err := c.doQueryWithProgressIndicator(queryName, p, variables)
 		if err != nil {
 			return nodes, err
 		}
@@ -863,7 +855,7 @@ func (c *Client) ProjectFields(o *Owner, number int32, limit int) (*Project, err
 		query = &viewerOwnerWithFields{} // must be a pointer to work with graphql queries
 		queryName = "ViewerProjectWithFields"
 	}
-	err := c.doQuery(queryName, query, variables)
+	err := c.doQueryWithProgressIndicator(queryName, query, variables)
 	if err != nil {
 		return project, err
 	}
@@ -977,7 +969,7 @@ const ViewerOwner OwnerType = "VIEWER"
 // ViewerLoginName returns the login name of the viewer.
 func (c *Client) ViewerLoginName() (string, error) {
 	var query viewerLogin
-	err := c.doQuery("Viewer", &query, map[string]interface{}{})
+	err := c.doQueryWithProgressIndicator("Viewer", &query, map[string]interface{}{})
 	if err != nil {
 		return "", err
 	}
@@ -988,7 +980,7 @@ func (c *Client) ViewerLoginName() (string, error) {
 func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) {
 	if login == "@me" || login == "" {
 		var query viewerLogin
-		err := c.doQuery("ViewerOwner", &query, nil)
+		err := c.doQueryWithProgressIndicator("ViewerOwner", &query, nil)
 		if err != nil {
 			return "", "", err
 		}
@@ -1009,7 +1001,7 @@ func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) {
 		} `graphql:"organization(login: $login)"`
 	}
 
-	err := c.doQuery("UserOrgOwner", &query, variables)
+	err := c.doQueryWithProgressIndicator("UserOrgOwner", &query, variables)
 	if err != nil {
 		// Due to the way the queries are structured, we don't know if a login belongs to a user
 		// or to an org, even though they are unique. To deal with this, we try both - if neither
@@ -1052,7 +1044,7 @@ func (c *Client) IssueOrPullRequestID(rawURL string) (string, error) {
 		"url": githubv4.URI{URL: uri},
 	}
 	var query issueOrPullRequest
-	err = c.doQuery("GetIssueOrPullRequest", &query, variables)
+	err = c.doQueryWithProgressIndicator("GetIssueOrPullRequest", &query, variables)
 	if err != nil {
 		return "", err
 	}
@@ -1114,7 +1106,7 @@ func (c *Client) userOrgLogins() ([]loginTypes, error) {
 		"after": (*githubv4.String)(nil),
 	}
 
-	err := c.doQuery("ViewerLoginAndOrgs", &v, variables)
+	err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables)
 	if err != nil {
 		return l, err
 	}
@@ -1152,7 +1144,7 @@ func (c *Client) paginateOrgLogins(l []loginTypes, cursor string) ([]loginTypes,
 		"after": githubv4.String(cursor),
 	}
 
-	err := c.doQuery("ViewerLoginAndOrgs", &v, variables)
+	err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables)
 	if err != nil {
 		return l, err
 	}
@@ -1247,16 +1239,16 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool)
 		if o.Type == UserOwner {
 			var query userOwner
 			variables["login"] = githubv4.String(o.Login)
-			err := c.doQuery("UserProject", &query, variables)
+			err := c.doQueryWithProgressIndicator("UserProject", &query, variables)
 			return &query.Owner.Project, err
 		} else if o.Type == OrgOwner {
 			variables["login"] = githubv4.String(o.Login)
 			var query orgOwner
-			err := c.doQuery("OrgProject", &query, variables)
+			err := c.doQueryWithProgressIndicator("OrgProject", &query, variables)
 			return &query.Owner.Project, err
 		} else if o.Type == ViewerOwner {
 			var query viewerOwner
-			err := c.doQuery("ViewerProject", &query, variables)
+			err := c.doQueryWithProgressIndicator("ViewerProject", &query, variables)
 			return &query.Owner.Project, err
 		}
 		return nil, errors.New("unknown owner type")
@@ -1331,7 +1323,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
 		// the cost.
 		if t == UserOwner {
 			var query userProjects
-			if err := c.doQuery("UserProjects", &query, variables); err != nil {
+			if err := c.doQueryWithProgressIndicator("UserProjects", &query, variables); err != nil {
 				return projects, err
 			}
 			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
@@ -1340,7 +1332,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
 			projects.TotalCount = query.Owner.Projects.TotalCount
 		} else if t == OrgOwner {
 			var query orgProjects
-			if err := c.doQuery("OrgProjects", &query, variables); err != nil {
+			if err := c.doQueryWithProgressIndicator("OrgProjects", &query, variables); err != nil {
 				return projects, err
 			}
 			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
@@ -1349,7 +1341,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
 			projects.TotalCount = query.Owner.Projects.TotalCount
 		} else if t == ViewerOwner {
 			var query viewerProjects
-			if err := c.doQuery("ViewerProjects", &query, variables); err != nil {
+			if err := c.doQueryWithProgressIndicator("ViewerProjects", &query, variables); err != nil {
 				return projects, err
 			}
 			projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index be6d0f65a..7e3a1d43a 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -78,7 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 	cmd := &cobra.Command{
 		DisableFlagsInUseLine: true,
 
-		Use:   "create [<tag>] [<files>...]",
+		Use:   "create [<tag>] [<filename>... | <pattern>...]",
 		Short: "Create a new release",
 		Long: heredoc.Docf(`
 			Create a new GitHub Release for a repository.
diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go
index 24ec41840..dfc3ba8c1 100644
--- a/pkg/cmd/release/list/list.go
+++ b/pkg/cmd/release/list/list.go
@@ -88,7 +88,7 @@ func listRun(opts *ListOptions) error {
 	}
 
 	table := tableprinter.New(opts.IO, tableprinter.WithHeader("Title", "Type", "Tag name", "Published"))
-	iofmt := opts.IO.ColorScheme()
+	cs := opts.IO.ColorScheme()
 	for _, rel := range releases {
 		title := text.RemoveExcessiveWhitespace(rel.Name)
 		if title == "" {
@@ -100,13 +100,13 @@ func listRun(opts *ListOptions) error {
 		var badgeColor func(string) string
 		if rel.IsLatest {
 			badge = "Latest"
-			badgeColor = iofmt.Green
+			badgeColor = cs.Green
 		} else if rel.IsDraft {
 			badge = "Draft"
-			badgeColor = iofmt.Red
+			badgeColor = cs.Red
 		} else if rel.IsPrerelease {
 			badge = "Pre-release"
-			badgeColor = iofmt.Yellow
+			badgeColor = cs.Yellow
 		}
 		table.AddField(badge, tableprinter.WithColor(badgeColor))
 
@@ -116,7 +116,7 @@ func listRun(opts *ListOptions) error {
 		if rel.PublishedAt.IsZero() {
 			pubDate = rel.CreatedAt
 		}
-		table.AddTimeField(time.Now(), pubDate, iofmt.Gray)
+		table.AddTimeField(time.Now(), pubDate, cs.Muted)
 		table.EndRow()
 	}
 	err = table.Render()
diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go
index 38add7425..ab7533320 100644
--- a/pkg/cmd/release/shared/upload.go
+++ b/pkg/cmd/release/shared/upload.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/cenkalti/backoff/v4"
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/pkg/cmdutil"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -36,6 +37,17 @@ type AssetForUpload struct {
 }
 
 func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
+	labeledArgs, unlabeledArgs := cmdutil.Partition(args, func(arg string) bool {
+		return strings.Contains(arg, "#")
+	})
+
+	args, err = cmdutil.GlobPaths(unlabeledArgs)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, labeledArgs...)
+
 	for _, arg := range args {
 		var label string
 		fn := arg
diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go
index a32482e65..c9030f299 100644
--- a/pkg/cmd/release/view/view.go
+++ b/pkg/cmd/release/view/view.go
@@ -129,19 +129,19 @@ func viewRun(opts *ViewOptions) error {
 }
 
 func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
-	iofmt := io.ColorScheme()
+	cs := io.ColorScheme()
 	w := io.Out
 
-	fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName))
+	fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName))
 	if release.IsDraft {
-		fmt.Fprintf(w, "%s • ", iofmt.Red("Draft"))
+		fmt.Fprintf(w, "%s • ", cs.Red("Draft"))
 	} else if release.IsPrerelease {
-		fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release"))
+		fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release"))
 	}
 	if release.IsDraft {
-		fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))))
+		fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt)))
 	} else {
-		fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))))
+		fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt)))
 	}
 
 	renderedDescription, err := markdown.Render(release.Body,
@@ -153,7 +153,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
 	fmt.Fprintln(w, renderedDescription)
 
 	if len(release.Assets) > 0 {
-		fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets"))
+		fmt.Fprintln(w, cs.Bold("Assets"))
 		//nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table.
 		table := tableprinter.New(io, tableprinter.NoHeader)
 		for _, a := range release.Assets {
@@ -168,7 +168,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
 		fmt.Fprint(w, "\n")
 	}
 
-	fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL)))
+	fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL))
 	return nil
 }
 
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index cd7c56ea8..be0be2ddd 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -262,10 +262,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 
 func createRun(opts *CreateOptions) error {
 	if opts.Interactive {
+		cfg, err := opts.Config()
+		if err != nil {
+			return err
+		}
+		host, _ := cfg.Authentication().DefaultHost()
 		answer, err := opts.Prompter.Select("What would you like to do?", "", []string{
-			"Create a new repository on GitHub from scratch",
-			"Create a new repository on GitHub from a template repository",
-			"Push an existing local repository to GitHub",
+			fmt.Sprintf("Create a new repository on %s from scratch", host),
+			fmt.Sprintf("Create a new repository on %s from a template repository", host),
+			fmt.Sprintf("Push an existing local repository to %s", host),
 		})
 		if err != nil {
 			return err
@@ -323,7 +328,9 @@ func createFromScratch(opts *CreateOptions) error {
 		if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
 			targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
 		}
-		confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
+		confirmed, err := opts.Prompter.Confirm(
+			fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
+			true)
 		if err != nil {
 			return err
 		} else if !confirmed {
@@ -392,9 +399,10 @@ func createFromScratch(opts *CreateOptions) error {
 	isTTY := opts.IO.IsStdoutTTY()
 	if isTTY {
 		fmt.Fprintf(opts.IO.Out,
-			"%s Created repository %s on GitHub\n  %s\n",
+			"%s Created repository %s on %s\n  %s\n",
 			cs.SuccessIconWithColor(cs.Green),
 			ghrepo.FullName(repo),
+			host,
 			repo.URL)
 	} else {
 		fmt.Fprintln(opts.IO.Out, repo.URL)
@@ -482,7 +490,9 @@ func createFromTemplate(opts *CreateOptions) error {
 	if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
 		targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
 	}
-	confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
+	confirmed, err := opts.Prompter.Confirm(
+		fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
+		true)
 	if err != nil {
 		return err
 	} else if !confirmed {
@@ -496,9 +506,10 @@ func createFromTemplate(opts *CreateOptions) error {
 
 	cs := opts.IO.ColorScheme()
 	fmt.Fprintf(opts.IO.Out,
-		"%s Created repository %s on GitHub\n  %s\n",
+		"%s Created repository %s on %s\n  %s\n",
 		cs.SuccessIconWithColor(cs.Green),
 		ghrepo.FullName(repo),
+		host,
 		repo.URL)
 
 	opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true)
@@ -622,9 +633,10 @@ func createFromLocal(opts *CreateOptions) error {
 
 	if isTTY {
 		fmt.Fprintf(stdout,
-			"%s Created repository %s on GitHub\n  %s\n",
+			"%s Created repository %s on %s\n  %s\n",
 			cs.SuccessIconWithColor(cs.Green),
 			ghrepo.FullName(repo),
+			host,
 			repo.URL)
 	} else {
 		fmt.Fprintln(stdout, repo.URL)
diff --git a/pkg/cmd/repo/deploy-key/list/list.go b/pkg/cmd/repo/deploy-key/list/list.go
index 0cf2861c1..79d00c912 100644
--- a/pkg/cmd/repo/deploy-key/list/list.go
+++ b/pkg/cmd/repo/deploy-key/list/list.go
@@ -90,7 +90,7 @@ func listRun(opts *ListOptions) error {
 		}
 		t.AddField(sshType)
 		t.AddField(deployKey.Key, tableprinter.WithTruncate(truncateMiddle))
-		t.AddTimeField(now, deployKey.CreatedAt, cs.Gray)
+		t.AddTimeField(now, deployKey.CreatedAt, cs.Muted)
 		t.EndRow()
 	}
 
diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go
index d3e63c241..14228ba36 100644
--- a/pkg/cmd/repo/license/view/view.go
+++ b/pkg/cmd/repo/license/view/view.go
@@ -119,9 +119,9 @@ func renderLicense(license *api.License, opts *ViewOptions) error {
 	cs := opts.IO.ColorScheme()
 	var out strings.Builder
 	if opts.IO.IsStdoutTTY() {
-		out.WriteString(fmt.Sprintf("\n%s\n", cs.Gray(license.Description)))
-		out.WriteString(fmt.Sprintf("\n%s\n", cs.Grayf("To implement: %s", license.Implementation)))
-		out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Grayf("For more information, see: %s", license.HTMLURL)))
+		out.WriteString(fmt.Sprintf("\n%s\n", cs.Muted(license.Description)))
+		out.WriteString(fmt.Sprintf("\n%s\n", cs.Mutedf("To implement: %s", license.Implementation)))
+		out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Mutedf("For more information, see: %s", license.HTMLURL)))
 	}
 	out.WriteString(license.Body)
 	_, err := opts.IO.Out.Write([]byte(out.String()))
diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go
index 2cee2c480..116439290 100644
--- a/pkg/cmd/repo/list/list.go
+++ b/pkg/cmd/repo/list/list.go
@@ -181,7 +181,7 @@ func listRun(opts *ListOptions) error {
 	totalMatchCount := len(listResult.Repositories)
 	for _, repo := range listResult.Repositories {
 		info := repoInfo(repo)
-		infoColor := cs.Gray
+		infoColor := cs.Muted
 
 		if repo.IsPrivate {
 			infoColor = cs.Yellow
@@ -195,7 +195,7 @@ func listRun(opts *ListOptions) error {
 		tp.AddField(repo.NameWithOwner, tableprinter.WithColor(cs.Bold))
 		tp.AddField(text.RemoveExcessiveWhitespace(repo.Description))
 		tp.AddField(info, tableprinter.WithColor(infoColor))
-		tp.AddTimeField(opts.Now(), *t, cs.Gray)
+		tp.AddTimeField(opts.Now(), *t, cs.Muted)
 		tp.EndRow()
 	}
 
diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go
index 3e193daeb..27e9a6351 100644
--- a/pkg/cmd/repo/sync/http.go
+++ b/pkg/cmd/repo/sync/http.go
@@ -32,8 +32,8 @@ func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (com
 
 type upstreamMergeErr struct{ error }
 
-var missingWorkflowScopeRE = regexp.MustCompile("refusing to allow.*without `workflow` scope")
-var missingWorkflowScopeErr = errors.New("Upstream commits contain workflow changes, which require the `workflow` scope to merge. To request it, run: gh auth refresh -s workflow")
+var missingWorkflowScopeRE = regexp.MustCompile("refusing to allow.*without `workflow(s)?` (scope|permission)")
+var missingWorkflowScopeErr = errors.New("Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow")
 
 func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch string) (string, error) {
 	var payload bytes.Buffer
diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go
index 06f85d048..b13276c80 100644
--- a/pkg/cmd/repo/view/view.go
+++ b/pkg/cmd/repo/view/view.go
@@ -181,7 +181,7 @@ func viewRun(opts *ViewOptions) error {
 
 	var readmeContent string
 	if readme == nil {
-		readmeContent = cs.Gray("This repository does not have a README")
+		readmeContent = cs.Muted("This repository does not have a README")
 	} else if isMarkdownFile(readme.Filename) {
 		var err error
 		readmeContent, err = markdown.Render(readme.Content,
@@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error {
 
 	description := repo.Description
 	if description == "" {
-		description = cs.Gray("No description provided")
+		description = cs.Muted("No description provided")
 	}
 
 	repoData := struct {
@@ -209,7 +209,7 @@ func viewRun(opts *ViewOptions) error {
 		FullName:    cs.Bold(fullName),
 		Description: description,
 		Readme:      readmeContent,
-		View:        cs.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)),
+		View:        cs.Mutedf("View this repository on GitHub: %s", openURL),
 	}
 
 	return tmpl.Execute(stdout, repoData)
diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go
index a7daa7b84..7f8fb1c2e 100644
--- a/pkg/cmd/root/help.go
+++ b/pkg/cmd/root/help.go
@@ -87,7 +87,7 @@ func isRootCmd(command *cobra.Command) bool {
 	return command != nil && !command.HasParent()
 }
 
-func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
+func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
 	flags := command.Flags()
 
 	if isRootCmd(command) {
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index 2b6432ef9..0c9534306 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -81,6 +81,11 @@ var HelpTopics = []helpTopic{
 			%[1]sCLICOLOR_FORCE%[1]s: set to a value other than %[1]s0%[1]s to keep ANSI colors in output
 			even when the output is piped.
 
+			%[1]sGH_COLOR_LABELS%[1]s: set to any value to display labels using their RGB hex color codes in terminals that
+			support truecolor.
+
+			%[1]sGH_ACCESSIBLE_COLORS%[1]s (preview): set to a truthy value to use customizable, 4-bit accessible colors.
+
 			%[1]sGH_FORCE_TTY%[1]s: set to any value to force terminal-style output even when the output is
 			redirected. When the value is a number, it is interpreted as the number of columns
 			available in the viewport. When the value is a percentage, it will be applied against
@@ -108,6 +113,12 @@ var HelpTopics = []helpTopic{
 			%[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping.  The max width of lines
 			wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if
 			not specified.  This value is used, for example, with %[1]spr view%[1]s subcommand.
+
+			%[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are
+			more compatible with speech synthesis and braille screen readers.
+
+			%[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with
+			a textual progress indicator.
 		`, "`"),
 	},
 	{
@@ -138,6 +149,7 @@ var HelpTopics = []helpTopic{
 
 			The %[1]s--template%[1]s flag requires a string argument in Go template syntax, and will only print
 			those JSON values which match the query.
+
 			In addition to the Go template functions in the standard library, the following functions can be used
 			with this formatting directive:
 			- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
@@ -151,6 +163,14 @@ var HelpTopics = []helpTopic{
 			- %[1]struncate <length> <input>%[1]s: ensures input fits within length
 			- %[1]shyperlink <url> <text>%[1]s: renders a terminal hyperlink
 
+			The following Sprig template library functions can also be used with this formatting directive:
+			- %[1]scontains <arg> <string>%[1]s: checks if %[1]sstring%[1]s contains %[1]sarg%[1]s
+			- %[1]shasPrefix <prefix> <string>%[1]s: checks if %[1]sstring%[1]s starts with %[1]sprefix%[1]s
+			- %[1]shasSuffix <suffix> <string>%[1]s: checks if %[1]sstring%[1]s ends with %[1]ssuffix%[1]s
+			- %[1]sregexMatch <regex> <string>%[1]s: checks if %[1]sstring%[1]s has any matches for %[1]sregex%[1]s
+
+			For more information about the Sprig library, see <https://masterminds.github.io/sprig/>.
+
 			To learn more about Go templates, see: <https://golang.org/pkg/text/template/>.
 		`, "`"),
 		example: heredoc.Doc(`
diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go
index 3113fdabd..7128ae253 100644
--- a/pkg/cmd/run/list/list.go
+++ b/pkg/cmd/run/list/list.go
@@ -62,6 +62,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
 
 			Note that providing the %[1]sworkflow_name%[1]s to the %[1]s-w%[1]s flag will not fetch disabled workflows.
 			Also pass the %[1]s-a%[1]s flag to fetch disabled workflow runs using the %[1]sworkflow_name%[1]s and the %[1]s-w%[1]s flag.
+
+			Runs created by organization and enterprise ruleset workflows will not display a workflow name due to GitHub API limitations.
 		`, "`"),
 		Aliases: []string{"ls"},
 		Args:    cobra.NoArgs,
@@ -174,7 +176,7 @@ func listRun(opts *ListOptions) error {
 		tp.AddField(string(run.Event))
 		tp.AddField(fmt.Sprintf("%d", run.ID), tableprinter.WithColor(cs.Cyan))
 		tp.AddField(run.Duration(opts.now).String())
-		tp.AddTimeField(opts.now, run.StartedTime(), cs.Gray)
+		tp.AddTimeField(opts.now, run.StartedTime(), cs.Muted)
 		tp.EndRow()
 	}
 
diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go
index 2ec149729..a3556d743 100644
--- a/pkg/cmd/run/shared/presentation.go
+++ b/pkg/cmd/run/shared/presentation.go
@@ -52,7 +52,8 @@ func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) stri
 
 	for _, a := range annotations {
 		lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message))
-		lines = append(lines, cs.Grayf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine))
+		// Following newline is essential for spacing between annotations
+		lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine))
 	}
 
 	return strings.Join(lines, "\n")
diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go
index 040888ab5..b58f6b0f7 100644
--- a/pkg/cmd/run/shared/shared.go
+++ b/pkg/cmd/run/shared/shared.go
@@ -230,6 +230,8 @@ type Job struct {
 	CompletedAt time.Time `json:"completed_at"`
 	URL         string    `json:"html_url"`
 	RunID       int64     `json:"run_id"`
+
+	Log *zip.File
 }
 
 type Step struct {
@@ -239,7 +241,8 @@ type Step struct {
 	Number      int
 	StartedAt   time.Time `json:"started_at"`
 	CompletedAt time.Time `json:"completed_at"`
-	Log         *zip.File
+
+	Log *zip.File
 }
 
 type Steps []Step
@@ -446,6 +449,18 @@ func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run)
 		if _, ok := workflowMap[run.WorkflowID]; !ok {
 			// Look up workflow by ID because it may have been deleted
 			workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID)
+			// If the error is an httpError and it is a 404, this is likely a
+			// organization or enterprise ruleset workflow. The user does not
+			// have permissions to view the details of the workflow, so we cannot
+			// look it up directly without receiving a 404, but it is nonetheless
+			// in the workflow run list. To handle this, we set the workflow name
+			// to an empty string.
+			// Deciding to put this here instead of in GetWorkflow to allow
+			// the caller to decide what a 404 means.
+			if httpErr, ok := err.(api.HTTPError); ok && httpErr.StatusCode == 404 {
+				workflowMap[run.WorkflowID] = ""
+				continue
+			}
 			if err != nil {
 				return err
 			}
@@ -563,7 +578,7 @@ func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (st
 		case Success:
 			return cs.SuccessIconWithColor(noColor), cs.Green
 		case Skipped, Neutral:
-			return "-", cs.Gray
+			return "-", cs.Muted
 		default:
 			return cs.FailureIconWithColor(noColor), cs.Red
 		}
diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip
index 757a63983..425ba09dd 100644
Binary files a/pkg/cmd/run/view/fixtures/run_log.zip and b/pkg/cmd/run/view/fixtures/run_log.zip differ
diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go
index c794cff9a..0dafbcc09 100644
--- a/pkg/cmd/run/view/view.go
+++ b/pkg/cmd/run/view/view.go
@@ -118,6 +118,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
 
 			This command does not support authenticating via fine grained PATs
 			as it is not currently possible to create a PAT with the %[1]schecks:read%[1]s permission.
+
+			Due to platform limitations, %[1]sgh%[1]s may not always be able to associate log lines with a
+			particular step in a job. In this case, the step name in the log output will be replaced with
+			%[1]sUNKNOWN STEP%[1]s.
 		`, "`"),
 		Args: cobra.MaximumNArgs(1),
 		Example: heredoc.Doc(`
@@ -397,7 +401,7 @@ func runView(opts *ViewOptions) error {
 			for _, a := range artifacts {
 				expiredBadge := ""
 				if a.Expired {
-					expiredBadge = cs.Gray(" (expired)")
+					expiredBadge = cs.Muted(" (expired)")
 				}
 				fmt.Fprintf(out, "%s%s\n", a.Name, expiredBadge)
 			}
@@ -411,7 +415,7 @@ func runView(opts *ViewOptions) error {
 		} else {
 			fmt.Fprintf(out, "For more information about a job, try: gh run view --job=<job-id>\n")
 		}
-		fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
+		fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL))
 
 		if opts.ExitStatus && shared.IsFailureState(run.Conclusion) {
 			return cmdutil.SilentError
@@ -423,7 +427,7 @@ func runView(opts *ViewOptions) error {
 		} else {
 			fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.ID)
 		}
-		fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
+		fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL))
 
 		if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) {
 			return cmdutil.SilentError
@@ -533,7 +537,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh
 
 const JOB_NAME_MAX_LENGTH = 90
 
-func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
+func getJobNameForLogFilename(name string) string {
 	// As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps
 	// the server can take when producing the downloaded zip file that can result in a mismatch between the job name
 	// and the filename in the zip including:
@@ -545,10 +549,31 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
 	// * Strip `/` which occur when composite action job names are constructed of the form `<JOB_NAME`> / <ACTION_NAME>`
 	// * Truncate long job names
 	//
-	sanitizedJobName := strings.ReplaceAll(job.Name, "/", "")
+	sanitizedJobName := strings.ReplaceAll(name, "/", "")
 	sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "")
 	sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH)
-	re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number)
+	return sanitizedJobName
+}
+
+// A job run log file is a top-level .txt file whose name starts with an ordinal
+// number; e.g., "0_jobname.txt".
+func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
+	return regexp.MustCompile(re)
+}
+
+// A legacy job run log file is a top-level .txt file whose name starts with a
+// negative number which is the ID of the run; e.g., "-2147483648_jobname.txt".
+func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^-\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
+	return regexp.MustCompile(re)
+}
+
+func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
+	sanitizedJobName := getJobNameForLogFilename(job.Name)
+	re := fmt.Sprintf(`^%s\/%d_.*\.txt$`, regexp.QuoteMeta(sanitizedJobName), step.Number)
 	return regexp.MustCompile(re)
 }
 
@@ -627,29 +652,60 @@ func truncateAsUTF16(str string, max int) string {
 //	│   ├── 2_anotherstepname.txt
 //	│   ├── 3_stepstepname.txt
 //	│   └── 4_laststepname.txt
-//	└── jobname2/
-//	    ├── 1_stepname.txt
-//	    └── 2_somestepname.txt
+//	├── jobname2/
+//	|   ├── 1_stepname.txt
+//	|   └── 2_somestepname.txt
+//	├── 0_jobname1.txt
+//	├── 1_jobname2.txt
+//	└── -9999999999_jobname3.txt
 //
 // It iterates through the list of jobs and tries to find the matching
 // log in the zip file. If the matching log is found it is attached
 // to the job.
+//
+// The top-level .txt files include the logs for an entire job run. Note that
+// the prefixed number is either:
+//   - An ordinal and cannot be mapped to the corresponding job's ID.
+//   - A negative integer which is the ID of the job in the old Actions service.
+//     The service right now tries to get logs and use an ordinal in a loop.
+//     However, if it doesn't get the logs, it falls back to an old service
+//     where the ID can apparently be negative.
 func attachRunLog(rlz *zip.Reader, jobs []shared.Job) {
 	for i, job := range jobs {
+		// As a highest priority, we try to use the step logs first. We have seen zips that surprisingly contain
+		// step logs, normal job logs and legacy job logs. In this case, both job logs would be ignored. We have
+		// never seen a zip containing both job logs and no step logs, however, it may be possible. In that case
+		// let's prioritise the normal log over the legacy one.
+		jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job))
+		if jobLog == nil {
+			jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job))
+		}
+		jobs[i].Log = jobLog
+
 		for j, step := range job.Steps {
-			re := logFilenameRegexp(job, step)
-			for _, file := range rlz.File {
-				if re.MatchString(file.Name) {
-					jobs[i].Steps[j].Log = file
-					break
-				}
-			}
+			jobs[i].Steps[j].Log = matchFileInZIPArchive(rlz, stepLogFilenameRegexp(job, step))
 		}
 	}
 }
 
+func matchFileInZIPArchive(zr *zip.Reader, re *regexp.Regexp) *zip.File {
+	for _, file := range zr.File {
+		if re.MatchString(file.Name) {
+			return file
+		}
+	}
+	return nil
+}
+
 func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
 	for _, job := range jobs {
+		// To display a run log, we first try to compile it from individual step
+		// logs, because this way we can prepend lines with the corresponding
+		// step name. However, at the time of writing, logs are sometimes being
+		// served by a service that doesn’t include the step logs (none of them),
+		// in which case we fall back to print the entire job run log.
+		var hasStepLogs bool
+
 		steps := job.Steps
 		sort.Sort(steps)
 		for _, step := range steps {
@@ -659,18 +715,49 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
 			if step.Log == nil {
 				continue
 			}
+			hasStepLogs = true
 			prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name)
-			f, err := step.Log.Open()
-			if err != nil {
+			if err := printZIPFile(w, step.Log, prefix); err != nil {
 				return err
 			}
-			scanner := bufio.NewScanner(f)
-			for scanner.Scan() {
-				fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
-			}
-			f.Close()
+		}
+
+		if hasStepLogs {
+			continue
+		}
+
+		if failed && !shared.IsFailureState(job.Conclusion) {
+			continue
+		}
+
+		if job.Log == nil {
+			continue
+		}
+
+		// Here, we fall back to the job run log, which means we do not know
+		// the step name of lines. However, we want to keep the same line
+		// formatting to avoid breaking any code or script that rely on the
+		// tab-delimited formatting. So, an unknown-step placeholder is used
+		// instead of the actual step name.
+		prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name)
+		if err := printZIPFile(w, job.Log, prefix); err != nil {
+			return err
 		}
 	}
 
 	return nil
 }
+
+func printZIPFile(w io.Writer, file *zip.File, prefix string) error {
+	f, err := file.Open()
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
+	}
+	return nil
+}
diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go
index bc37684a2..fb1742dc9 100644
--- a/pkg/cmd/search/commits/commits.go
+++ b/pkg/cmd/search/commits/commits.go
@@ -161,7 +161,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Commi
 		tp.AddField(commit.Sha)
 		tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message))
 		tp.AddField(commit.Author.Login)
-		tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray)
+		tp.AddTimeField(now, commit.Info.Author.Date, cs.Muted)
 		tp.EndRow()
 	}
 	if io.IsStdoutTTY() {
diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go
index 0bad650d3..2815ee6dc 100644
--- a/pkg/cmd/search/repos/repos.go
+++ b/pkg/cmd/search/repos/repos.go
@@ -171,14 +171,14 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos
 			tags = append(tags, "archived")
 		}
 		info := strings.Join(tags, ", ")
-		infoColor := cs.Gray
+		infoColor := cs.Muted
 		if repo.IsPrivate {
 			infoColor = cs.Yellow
 		}
 		tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
 		tp.AddField(text.RemoveExcessiveWhitespace(repo.Description))
 		tp.AddField(info, tableprinter.WithColor(infoColor))
-		tp.AddTimeField(now, repo.UpdatedAt, cs.Gray)
+		tp.AddTimeField(now, repo.UpdatedAt, cs.Muted)
 		tp.EndRow()
 	}
 	if io.IsStdoutTTY() {
diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go
index f0a346fc8..3282599cf 100644
--- a/pkg/cmd/search/shared/shared.go
+++ b/pkg/cmd/search/shared/shared.go
@@ -132,7 +132,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType,
 		}
 		tp.AddField(text.RemoveExcessiveWhitespace(issue.Title))
 		tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()))
-		tp.AddTimeField(now, issue.UpdatedAt, cs.Gray)
+		tp.AddTimeField(now, issue.UpdatedAt, cs.Muted)
 		tp.EndRow()
 	}
 
@@ -158,7 +158,7 @@ func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bo
 	labelNames := make([]string, 0, len(issue.Labels))
 	for _, label := range issue.Labels {
 		if colorize {
-			labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
+			labelNames = append(labelNames, cs.Label(label.Color, label.Name))
 		} else {
 			labelNames = append(labelNames, label.Name)
 		}
diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go
index 84a5e7a83..b73b59848 100644
--- a/pkg/cmd/secret/delete/delete.go
+++ b/pkg/cmd/secret/delete/delete.go
@@ -3,6 +3,7 @@ package delete
 import (
 	"fmt"
 	"net/http"
+	"os"
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
@@ -49,7 +50,8 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
 			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
+			if !isRepoUserProvided {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)
diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go
index af3526386..06476a86d 100644
--- a/pkg/cmd/secret/list/list.go
+++ b/pkg/cmd/secret/list/list.go
@@ -3,6 +3,7 @@ package list
 import (
 	"fmt"
 	"net/http"
+	"os"
 	"slices"
 	"strings"
 	"time"
@@ -70,7 +71,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
 			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
+			if !isRepoUserProvided {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)
diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go
index 3c12b4192..0a6581559 100644
--- a/pkg/cmd/secret/set/set.go
+++ b/pkg/cmd/secret/set/set.go
@@ -107,7 +107,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
 			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
+			if !isRepoUserProvided {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)
diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go
index eebc82f90..f69e57ea3 100644
--- a/pkg/cmd/ssh-key/list/list.go
+++ b/pkg/cmd/ssh-key/list/list.go
@@ -89,11 +89,11 @@ func listRun(opts *ListOptions) error {
 			t.AddField(id)
 			t.AddField(sshKey.Key, tableprinter.WithTruncate(truncateMiddle))
 			t.AddField(sshKey.Type)
-			t.AddTimeField(now, sshKey.CreatedAt, cs.Gray)
+			t.AddTimeField(now, sshKey.CreatedAt, cs.Muted)
 		} else {
 			t.AddField(sshKey.Title)
 			t.AddField(sshKey.Key)
-			t.AddTimeField(now, sshKey.CreatedAt, cs.Gray)
+			t.AddTimeField(now, sshKey.CreatedAt, cs.Muted)
 			t.AddField(id)
 			t.AddField(sshKey.Type)
 		}
diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go
index 0ed7dd3a7..c9acce8bd 100644
--- a/pkg/cmd/status/status.go
+++ b/pkg/cmd/status/status.go
@@ -740,7 +740,7 @@ func statusRun(opts *StatusOptions) error {
 		errs := sg.authErrors.ToSlice()
 		sort.Strings(errs)
 		for _, msg := range errs {
-			fmt.Fprintln(out, cs.Gray(fmt.Sprintf("warning: %s", msg)))
+			fmt.Fprintln(out, cs.Mutedf("warning: %s", msg))
 		}
 	}
 
diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go
index 188d79e22..2e550f496 100644
--- a/pkg/cmd/workflow/view/view.go
+++ b/pkg/cmd/workflow/view/view.go
@@ -175,7 +175,7 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Inte
 		out := opts.IO.Out
 
 		fileName := workflow.Base()
-		fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName))
+		fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Muted(fileName))
 		fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID))
 
 		codeBlock := fmt.Sprintf("```yaml\n%s\n```", yaml)
diff --git a/pkg/cmdutil/args.go b/pkg/cmdutil/args.go
index 0f03a07bd..b0a22e9b5 100644
--- a/pkg/cmdutil/args.go
+++ b/pkg/cmdutil/args.go
@@ -2,6 +2,7 @@ package cmdutil
 
 import (
 	"fmt"
+	"path/filepath"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
@@ -57,3 +58,43 @@ func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error {
 
 	return FlagErrorf("%s", errMsg)
 }
+
+// Partition takes a slice of any type T and separates it into two slices
+// of the same type based on the provided predicate function. Any item
+// that returns true for the predicate will be included in the first slice
+// returned, and any item that returns false for the predicate will be
+// included in the second slice returned.
+func Partition[T any](slice []T, predicate func(T) bool) ([]T, []T) {
+	var matching, nonMatching []T
+	for _, item := range slice {
+		if predicate(item) {
+			matching = append(matching, item)
+		} else {
+			nonMatching = append(nonMatching, item)
+		}
+	}
+	return matching, nonMatching
+}
+
+// GlobPaths expands a list of file patterns into a list of file paths.
+// If no files match a pattern, that pattern will return an error.
+// If no pattern is passed, this returns an empty list and no error.
+//
+// For information on supported glob patterns, see
+// https://pkg.go.dev/path/filepath#Match
+func GlobPaths(patterns []string) ([]string, error) {
+	expansions := []string{}
+
+	for _, pattern := range patterns {
+		matches, err := filepath.Glob(pattern)
+		if err != nil {
+			return nil, fmt.Errorf("%s: %v", pattern, err)
+		}
+		if len(matches) == 0 {
+			return []string{}, fmt.Errorf("no matches found for `%s`", pattern)
+		}
+		expansions = append(expansions, matches...)
+	}
+
+	return expansions, nil
+}
diff --git a/pkg/httpmock/registry.go b/pkg/httpmock/registry.go
index 387d0fc95..b7c5a117d 100644
--- a/pkg/httpmock/registry.go
+++ b/pkg/httpmock/registry.go
@@ -3,10 +3,10 @@ package httpmock
 import (
 	"fmt"
 	"net/http"
+	"runtime/debug"
+	"strings"
 	"sync"
 	"testing"
-
-	"github.com/stretchr/testify/assert"
 )
 
 // Replace http.Client transport layer with registry so all requests get
@@ -23,16 +23,28 @@ type Registry struct {
 
 func (r *Registry) Register(m Matcher, resp Responder) {
 	r.stubs = append(r.stubs, &Stub{
+		Stack:     string(debug.Stack()),
 		Matcher:   m,
 		Responder: resp,
 	})
 }
 
 func (r *Registry) Exclude(t *testing.T, m Matcher) {
+	registrationStack := string(debug.Stack())
+
 	excludedStub := &Stub{
 		Matcher: m,
 		Responder: func(req *http.Request) (*http.Response, error) {
-			assert.FailNowf(t, "Exclude error", "API called when excluded: %v", req.URL)
+			callStack := string(debug.Stack())
+
+			var errMsg strings.Builder
+			errMsg.WriteString("HTTP call was made when it should have been excluded:\n")
+			errMsg.WriteString(fmt.Sprintf("Request URL: %s\n", req.URL))
+			errMsg.WriteString(fmt.Sprintf("Was excluded by: %s\n", registrationStack))
+			errMsg.WriteString(fmt.Sprintf("Was called from: %s\n", callStack))
+
+			t.Error(errMsg.String())
+			t.FailNow()
 			return nil, nil
 		},
 		exclude: true,
@@ -46,17 +58,24 @@ type Testing interface {
 }
 
 func (r *Registry) Verify(t Testing) {
-	n := 0
+	var unmatchedStubStacks []string
 	for _, s := range r.stubs {
 		if !s.matched && !s.exclude {
-			n++
+			unmatchedStubStacks = append(unmatchedStubStacks, s.Stack)
 		}
 	}
-	if n > 0 {
+	if len(unmatchedStubStacks) > 0 {
 		t.Helper()
-		// NOTE: stubs offer no useful reflection, so we can't print details
+		stacks := strings.Builder{}
+		for i, stack := range unmatchedStubStacks {
+			stacks.WriteString(fmt.Sprintf("Stub %d:\n", i+1))
+			stacks.WriteString(fmt.Sprintf("\t%s", stack))
+			if stack != unmatchedStubStacks[len(unmatchedStubStacks)-1] {
+				stacks.WriteString("\n")
+			}
+		}
 		// about dead stubs and what they were trying to match
-		t.Errorf("%d unmatched HTTP stubs", n)
+		t.Errorf("%d HTTP stubs unmatched, stacks:\n%s", len(unmatchedStubStacks), stacks.String())
 	}
 }
 
@@ -84,7 +103,7 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
 
 	if stub == nil {
 		r.mu.Unlock()
-		return nil, fmt.Errorf("no registered stubs matched %v", req)
+		return nil, fmt.Errorf("no registered HTTP stubs matched %v", req)
 	}
 
 	r.Requests = append(r.Requests, req)
diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go
index 4e61d12f4..745c12417 100644
--- a/pkg/httpmock/stub.go
+++ b/pkg/httpmock/stub.go
@@ -15,6 +15,7 @@ type Matcher func(req *http.Request) bool
 type Responder func(req *http.Request) (*http.Response, error)
 
 type Stub struct {
+	Stack     string
 	matched   bool
 	Matcher   Matcher
 	Responder Responder
diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go
index c8d48168f..f786e19cd 100644
--- a/pkg/iostreams/color.go
+++ b/pkg/iostreams/color.go
@@ -9,49 +9,56 @@ import (
 )
 
 const (
+	NoTheme        = "none"
+	DarkTheme      = "dark"
+	LightTheme     = "light"
 	highlightStyle = "black:yellow"
 )
 
+// Special cases like darkThemeTableHeader / lightThemeTableHeader are necessary when using color and modifiers
+// (bold, underline, dim) because ansi.ColorFunc requires a foreground color and resets formats.
 var (
-	magenta            = ansi.ColorFunc("magenta")
-	cyan               = ansi.ColorFunc("cyan")
-	red                = ansi.ColorFunc("red")
-	yellow             = ansi.ColorFunc("yellow")
-	blue               = ansi.ColorFunc("blue")
-	green              = ansi.ColorFunc("green")
-	gray               = ansi.ColorFunc("black+h")
-	lightGrayUnderline = ansi.ColorFunc("white+du")
-	bold               = ansi.ColorFunc("default+b")
-	cyanBold           = ansi.ColorFunc("cyan+b")
-	greenBold          = ansi.ColorFunc("green+b")
-	highlightStart     = ansi.ColorCode(highlightStyle)
-	highlight          = ansi.ColorFunc(highlightStyle)
+	magenta               = ansi.ColorFunc("magenta")
+	cyan                  = ansi.ColorFunc("cyan")
+	red                   = ansi.ColorFunc("red")
+	yellow                = ansi.ColorFunc("yellow")
+	blue                  = ansi.ColorFunc("blue")
+	green                 = ansi.ColorFunc("green")
+	gray                  = ansi.ColorFunc("black+h")
+	bold                  = ansi.ColorFunc("default+b")
+	cyanBold              = ansi.ColorFunc("cyan+b")
+	greenBold             = ansi.ColorFunc("green+b")
+	highlightStart        = ansi.ColorCode(highlightStyle)
+	highlight             = ansi.ColorFunc(highlightStyle)
+	darkThemeMuted        = ansi.ColorFunc("white+d")
+	darkThemeTableHeader  = ansi.ColorFunc("white+du")
+	lightThemeMuted       = ansi.ColorFunc("black+h")
+	lightThemeTableHeader = ansi.ColorFunc("black+hu")
+	noThemeTableHeader    = ansi.ColorFunc("default+u")
 
 	gray256 = func(t string) string {
-		return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
+		return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[0m", 38, 242, t)
 	}
 )
 
-func NewColorScheme(enabled, is256enabled bool, trueColor bool) *ColorScheme {
-	return &ColorScheme{
-		enabled:      enabled,
-		is256enabled: is256enabled,
-		hasTrueColor: trueColor,
-	}
-}
-
+// ColorScheme controls how text is colored based upon terminal capabilities and user preferences.
 type ColorScheme struct {
-	enabled      bool
-	is256enabled bool
-	hasTrueColor bool
-}
-
-func (c *ColorScheme) Enabled() bool {
-	return c.enabled
+	// Enabled is whether color is used at all.
+	Enabled bool
+	// EightBitColor is whether the terminal supports 8-bit, 256 colors.
+	EightBitColor bool
+	// TrueColor is whether the terminal supports 24-bit, 16 million colors.
+	TrueColor bool
+	// Accessible is whether colors must be base 16 colors that users can customize in terminal preferences.
+	Accessible bool
+	// ColorLabels is whether labels are colored based on their truecolor RGB hex color.
+	ColorLabels bool
+	// Theme is the terminal background color theme used to contextually color text for light, dark, or none at all.
+	Theme string
 }
 
 func (c *ColorScheme) Bold(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return bold(t)
@@ -61,8 +68,33 @@ func (c *ColorScheme) Boldf(t string, args ...interface{}) string {
 	return c.Bold(fmt.Sprintf(t, args...))
 }
 
+func (c *ColorScheme) Muted(t string) string {
+	// Fallback to previous logic if accessible colors preview is disabled.
+	if !c.Accessible {
+		return c.Gray(t)
+	}
+
+	// Muted text is only stylized if color is enabled.
+	if !c.Enabled {
+		return t
+	}
+
+	switch c.Theme {
+	case LightTheme:
+		return lightThemeMuted(t)
+	case DarkTheme:
+		return darkThemeMuted(t)
+	default:
+		return t
+	}
+}
+
+func (c *ColorScheme) Mutedf(t string, args ...interface{}) string {
+	return c.Muted(fmt.Sprintf(t, args...))
+}
+
 func (c *ColorScheme) Red(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return red(t)
@@ -73,7 +105,7 @@ func (c *ColorScheme) Redf(t string, args ...interface{}) string {
 }
 
 func (c *ColorScheme) Yellow(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return yellow(t)
@@ -84,7 +116,7 @@ func (c *ColorScheme) Yellowf(t string, args ...interface{}) string {
 }
 
 func (c *ColorScheme) Green(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return green(t)
@@ -95,35 +127,30 @@ func (c *ColorScheme) Greenf(t string, args ...interface{}) string {
 }
 
 func (c *ColorScheme) GreenBold(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return greenBold(t)
 }
 
+// Deprecated: Use Muted instead for thematically contrasting color.
 func (c *ColorScheme) Gray(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
-	if c.is256enabled {
+	if c.EightBitColor {
 		return gray256(t)
 	}
 	return gray(t)
 }
 
+// Deprecated: Use Mutedf instead for thematically contrasting color.
 func (c *ColorScheme) Grayf(t string, args ...interface{}) string {
 	return c.Gray(fmt.Sprintf(t, args...))
 }
 
-func (c *ColorScheme) LightGrayUnderline(t string) string {
-	if !c.enabled {
-		return t
-	}
-	return lightGrayUnderline(t)
-}
-
 func (c *ColorScheme) Magenta(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return magenta(t)
@@ -134,7 +161,7 @@ func (c *ColorScheme) Magentaf(t string, args ...interface{}) string {
 }
 
 func (c *ColorScheme) Cyan(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return cyan(t)
@@ -145,14 +172,14 @@ func (c *ColorScheme) Cyanf(t string, args ...interface{}) string {
 }
 
 func (c *ColorScheme) CyanBold(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return cyanBold(t)
 }
 
 func (c *ColorScheme) Blue(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 	return blue(t)
@@ -183,7 +210,7 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
 }
 
 func (c *ColorScheme) HighlightStart() string {
-	if !c.enabled {
+	if !c.Enabled {
 		return ""
 	}
 
@@ -191,7 +218,7 @@ func (c *ColorScheme) HighlightStart() string {
 }
 
 func (c *ColorScheme) Highlight(t string) string {
-	if !c.enabled {
+	if !c.Enabled {
 		return t
 	}
 
@@ -199,7 +226,7 @@ func (c *ColorScheme) Highlight(t string) string {
 }
 
 func (c *ColorScheme) Reset() string {
-	if !c.enabled {
+	if !c.Enabled {
 		return ""
 	}
 
@@ -219,7 +246,7 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string {
 	case "green":
 		fn = c.Green
 	case "gray":
-		fn = c.Gray
+		fn = c.Muted
 	case "magenta":
 		fn = c.Magenta
 	case "cyan":
@@ -235,17 +262,9 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string {
 	return fn
 }
 
-// ColorFromRGB returns a function suitable for TablePrinter.AddField
-// that calls HexToRGB, coloring text if supported by the terminal.
-func (c *ColorScheme) ColorFromRGB(hex string) func(string) string {
-	return func(s string) string {
-		return c.HexToRGB(hex, s)
-	}
-}
-
-// HexToRGB uses the given hex to color x if supported by the terminal.
-func (c *ColorScheme) HexToRGB(hex string, x string) string {
-	if !c.enabled || !c.hasTrueColor || len(hex) != 6 {
+// Label stylizes text based on label's RGB hex color.
+func (c *ColorScheme) Label(hex string, x string) string {
+	if !c.Enabled || !c.TrueColor || !c.ColorLabels || len(hex) != 6 {
 		return x
 	}
 
@@ -254,3 +273,19 @@ func (c *ColorScheme) HexToRGB(hex string, x string) string {
 	b, _ := strconv.ParseInt(hex[4:6], 16, 64)
 	return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x)
 }
+
+func (c *ColorScheme) TableHeader(t string) string {
+	// Table headers are only stylized if color is enabled including underline modifier.
+	if !c.Enabled {
+		return t
+	}
+
+	switch c.Theme {
+	case DarkTheme:
+		return darkThemeTableHeader(t)
+	case LightTheme:
+		return lightThemeTableHeader(t)
+	default:
+		return noThemeTableHeader(t)
+	}
+}
diff --git a/pkg/iostreams/console.go b/pkg/iostreams/console.go
index 89bdd1daa..72d070396 100644
--- a/pkg/iostreams/console.go
+++ b/pkg/iostreams/console.go
@@ -5,7 +5,7 @@ package iostreams
 
 import "os"
 
-func hasAlternateScreenBuffer(hasTrueColor bool) bool {
+func hasAlternateScreenBuffer(_ bool) bool {
 	// on non-Windows, we just assume that alternate screen buffer is supported in most cases
 	return os.Getenv("TERM") != "dumb"
 }
diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go
index 2bc712a3c..22f966ac8 100644
--- a/pkg/iostreams/iostreams.go
+++ b/pkg/iostreams/iostreams.go
@@ -58,6 +58,7 @@ type IOStreams struct {
 	progressIndicatorEnabled bool
 	progressIndicator        *spinner.Spinner
 	progressIndicatorMu      sync.Mutex
+	spinnerDisabled          bool
 
 	alternateScreenBufferEnabled bool
 	alternateScreenBufferActive  bool
@@ -70,13 +71,16 @@ type IOStreams struct {
 	stderrTTYOverride bool
 	stderrIsTTY       bool
 
-	colorOverride bool
-	colorEnabled  bool
+	colorOverride           bool
+	colorEnabled            bool
+	colorLabels             bool
+	accessibleColorsEnabled bool
 
 	pagerCommand string
 	pagerProcess *os.Process
 
-	neverPrompt bool
+	neverPrompt               bool
+	accessiblePrompterEnabled bool
 
 	TempFileOverride *os.File
 }
@@ -102,6 +106,10 @@ func (s *IOStreams) HasTrueColor() bool {
 	return s.term.IsTrueColorSupported()
 }
 
+func (s *IOStreams) ColorLabels() bool {
+	return s.colorLabels
+}
+
 // DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background
 // can be reliably detected.
 func (s *IOStreams) DetectTerminalTheme() {
@@ -134,6 +142,10 @@ func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
 	s.colorEnabled = colorEnabled
 }
 
+func (s *IOStreams) SetColorLabels(colorLabels bool) {
+	s.colorLabels = colorLabels
+}
+
 func (s *IOStreams) SetStdinTTY(isTTY bool) {
 	s.stdinTTYOverride = true
 	s.stdinIsTTY = isTTY
@@ -263,6 +275,14 @@ func (s *IOStreams) SetNeverPrompt(v bool) {
 	s.neverPrompt = v
 }
 
+func (s *IOStreams) GetSpinnerDisabled() bool {
+	return s.spinnerDisabled
+}
+
+func (s *IOStreams) SetSpinnerDisabled(v bool) {
+	s.spinnerDisabled = v
+}
+
 func (s *IOStreams) StartProgressIndicator() {
 	s.StartProgressIndicatorWithLabel("")
 }
@@ -272,6 +292,15 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
 		return
 	}
 
+	if s.spinnerDisabled {
+		// If the spinner is disabled, simply print a
+		// textual progress indicator and return.
+		// This means that s.ProgressIndicator will be nil.
+		// See also: the comment on StopProgressIndicator()
+		s.startTextualProgressIndicator(label)
+		return
+	}
+
 	s.progressIndicatorMu.Lock()
 	defer s.progressIndicatorMu.Unlock()
 
@@ -285,8 +314,10 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
 	}
 
 	// https://github.com/briandowns/spinner#available-character-sets
-	dotStyle := spinner.CharSets[11]
-	sp := spinner.New(dotStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
+	// ⣾ ⣷ ⣽ ⣻ ⡿
+	spinnerStyle := spinner.CharSets[11]
+
+	sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
 	if label != "" {
 		sp.Prefix = label + " "
 	}
@@ -295,6 +326,27 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
 	s.progressIndicator = sp
 }
 
+func (s *IOStreams) startTextualProgressIndicator(label string) {
+	s.progressIndicatorMu.Lock()
+	defer s.progressIndicatorMu.Unlock()
+
+	// Default label when spinner disabled is "Working..."
+	if label == "" {
+		label = "Working..."
+	}
+
+	// Add an ellipsis to the label if it doesn't already have one.
+	ellipsis := "..."
+	if !strings.HasSuffix(label, ellipsis) {
+		label = label + ellipsis
+	}
+
+	fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n")
+}
+
+// StopProgressIndicator stops the progress indicator if it is running.
+// Note that a textual progess indicator does not create a progress indicator,
+// so this method is a no-op in that case.
 func (s *IOStreams) StopProgressIndicator() {
 	s.progressIndicatorMu.Lock()
 	defer s.progressIndicatorMu.Unlock()
@@ -366,7 +418,14 @@ func (s *IOStreams) TerminalWidth() int {
 }
 
 func (s *IOStreams) ColorScheme() *ColorScheme {
-	return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor())
+	return &ColorScheme{
+		Enabled:       s.ColorEnabled(),
+		EightBitColor: s.ColorSupport256(),
+		TrueColor:     s.HasTrueColor(),
+		Accessible:    s.AccessibleColorsEnabled(),
+		ColorLabels:   s.ColorLabels(),
+		Theme:         s.TerminalTheme(),
+	}
 }
 
 func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {
@@ -391,6 +450,22 @@ func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) {
 	return os.CreateTemp(dir, pattern)
 }
 
+func (s *IOStreams) SetAccessibleColorsEnabled(enabled bool) {
+	s.accessibleColorsEnabled = enabled
+}
+
+func (s *IOStreams) AccessibleColorsEnabled() bool {
+	return s.accessibleColorsEnabled
+}
+
+func (s *IOStreams) SetAccessiblePrompterEnabled(enabled bool) {
+	s.accessiblePrompterEnabled = enabled
+}
+
+func (s *IOStreams) AccessiblePrompterEnabled() bool {
+	return s.accessiblePrompterEnabled
+}
+
 func System() *IOStreams {
 	terminal := ghTerm.FromEnv()
 
diff --git a/pkg/option/option.go b/pkg/option/option.go
index 8d3b70f3f..caf26dd0b 100644
--- a/pkg/option/option.go
+++ b/pkg/option/option.go
@@ -46,6 +46,15 @@ func None[T any]() Option[T] {
 	return Option[T]{}
 }
 
+func SomeIfNonZero[T comparable](value T) Option[T] {
+	// value is a zero value then return a None
+	var zero T
+	if value == zero {
+		return None[T]()
+	}
+	return Some(value)
+}
+
 // String implements the [fmt.Stringer] interface.
 func (o Option[T]) String() string {
 	if o.present {
diff --git a/pkg/search/result.go b/pkg/search/result.go
index 0c7c43cd7..0b9d1ab16 100644
--- a/pkg/search/result.go
+++ b/pkg/search/result.go
@@ -93,25 +93,29 @@ var PullRequestFields = append(IssueFields,
 type CodeResult struct {
 	IncompleteResults bool   `json:"incomplete_results"`
 	Items             []Code `json:"items"`
-	Total             int    `json:"total_count"`
+	// Number of code search results matching the query on the server. Ignoring limit.
+	Total int `json:"total_count"`
 }
 
 type CommitsResult struct {
 	IncompleteResults bool     `json:"incomplete_results"`
 	Items             []Commit `json:"items"`
-	Total             int      `json:"total_count"`
+	// Number of commits matching the query on the server. Ignoring limit.
+	Total int `json:"total_count"`
 }
 
 type RepositoriesResult struct {
 	IncompleteResults bool         `json:"incomplete_results"`
 	Items             []Repository `json:"items"`
-	Total             int          `json:"total_count"`
+	// Number of repositories matching the query on the server. Ignoring limit.
+	Total int `json:"total_count"`
 }
 
 type IssuesResult struct {
 	IncompleteResults bool    `json:"incomplete_results"`
 	Items             []Issue `json:"items"`
-	Total             int     `json:"total_count"`
+	// Number of isssues matching the query on the server. Ignoring limit.
+	Total int `json:"total_count"`
 }
 
 type Code struct {
diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go
index 4168dc7f3..7cbd35562 100644
--- a/pkg/search/searcher.go
+++ b/pkg/search/searcher.go
@@ -14,6 +14,7 @@ import (
 )
 
 const (
+	// GitHub API has a limit of 100 per page
 	maxPerPage = 100
 	orderKey   = "order"
 	sortKey    = "sort"
@@ -60,100 +61,145 @@ func NewSearcher(client *http.Client, host string) Searcher {
 
 func (s searcher) Code(query Query) (CodeResult, error) {
 	result := CodeResult{}
-	toRetrieve := query.Limit
+
 	var resp *http.Response
 	var err error
-	for toRetrieve > 0 {
-		query.Limit = min(toRetrieve, maxPerPage)
+
+	// We will request either the query limit if it's less than 1 page, or our max page size.
+	// This number doesn't change to keep a valid offset.
+	//
+	// For example, say we want 150 items out of 500.
+	// We request page #1 for 100 items and get items 0 to 99.
+	// Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149.
+	// If we were to request page #2 for 50 items, we would instead get items 50 to 99.
+	numItemsToRetrieve := query.Limit
+	query.Limit = min(numItemsToRetrieve, maxPerPage)
+
+	for numItemsToRetrieve > 0 {
 		query.Page = nextPage(resp)
 		if query.Page == 0 {
 			break
 		}
+
 		page := CodeResult{}
 		resp, err = s.search(query, &page)
 		if err != nil {
 			return result, err
 		}
+
+		// If we're going to reach the requested limit, only add that many items,
+		// otherwise add all the results.
+		numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
 		result.IncompleteResults = page.IncompleteResults
+		// The API returns how many items match the query in every response.
+		// With the example above, this would be 500.
 		result.Total = page.Total
-		result.Items = append(result.Items, page.Items...)
-		toRetrieve = toRetrieve - len(page.Items)
+		result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
+		numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
 	}
+
 	return result, nil
 }
 
 func (s searcher) Commits(query Query) (CommitsResult, error) {
 	result := CommitsResult{}
-	toRetrieve := query.Limit
+
 	var resp *http.Response
 	var err error
-	for toRetrieve > 0 {
-		query.Limit = min(toRetrieve, maxPerPage)
+
+	numItemsToRetrieve := query.Limit
+	query.Limit = min(numItemsToRetrieve, maxPerPage)
+
+	for numItemsToRetrieve > 0 {
 		query.Page = nextPage(resp)
 		if query.Page == 0 {
 			break
 		}
+
 		page := CommitsResult{}
 		resp, err = s.search(query, &page)
 		if err != nil {
 			return result, err
 		}
+
+		numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
 		result.IncompleteResults = page.IncompleteResults
 		result.Total = page.Total
-		result.Items = append(result.Items, page.Items...)
-		toRetrieve = toRetrieve - len(page.Items)
+		result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
+		numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
 	}
 	return result, nil
 }
 
 func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
 	result := RepositoriesResult{}
-	toRetrieve := query.Limit
+
 	var resp *http.Response
 	var err error
-	for toRetrieve > 0 {
-		query.Limit = min(toRetrieve, maxPerPage)
+
+	numItemsToRetrieve := query.Limit
+	query.Limit = min(numItemsToRetrieve, maxPerPage)
+
+	for numItemsToRetrieve > 0 {
 		query.Page = nextPage(resp)
 		if query.Page == 0 {
 			break
 		}
+
 		page := RepositoriesResult{}
 		resp, err = s.search(query, &page)
 		if err != nil {
 			return result, err
 		}
+
+		numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
 		result.IncompleteResults = page.IncompleteResults
 		result.Total = page.Total
-		result.Items = append(result.Items, page.Items...)
-		toRetrieve = toRetrieve - len(page.Items)
+		result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
+		numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
 	}
 	return result, nil
 }
 
 func (s searcher) Issues(query Query) (IssuesResult, error) {
 	result := IssuesResult{}
-	toRetrieve := query.Limit
+
 	var resp *http.Response
 	var err error
-	for toRetrieve > 0 {
-		query.Limit = min(toRetrieve, maxPerPage)
+
+	numItemsToRetrieve := query.Limit
+	query.Limit = min(numItemsToRetrieve, maxPerPage)
+
+	for numItemsToRetrieve > 0 {
 		query.Page = nextPage(resp)
 		if query.Page == 0 {
 			break
 		}
+
 		page := IssuesResult{}
 		resp, err = s.search(query, &page)
 		if err != nil {
 			return result, err
 		}
+
+		numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
 		result.IncompleteResults = page.IncompleteResults
 		result.Total = page.Total
-		result.Items = append(result.Items, page.Items...)
-		toRetrieve = toRetrieve - len(page.Items)
+		result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
+		numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
 	}
 	return result, nil
 }
 
+// search makes a single-page REST search request for code, commits, issues, prs, or repos.
+//
+// The result argument is populated with the following information:
+//
+// - Total: the number of search results matching the query, which may exceed the number of items returned
+// - IncompleteResults: whether the search request exceeded search time limit, potentially being incomplete
+// - Items: the actual matching search results, up to 100 max items per page
+//
+// For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28.
 func (s searcher) search(query Query, result interface{}) (*http.Response, error) {
 	path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind)
 	qs := url.Values{}
@@ -236,10 +282,15 @@ func handleHTTPError(resp *http.Response) error {
 	return httpError
 }
 
+// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
 func nextPage(resp *http.Response) (page int) {
 	if resp == nil {
 		return 1
 	}
+
+	// When using pagination, responses get a "Link" field in their header.
+	// When a next page is available, "Link" contains a link to the next page
+	// tagged with rel="next".
 	for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
 		if !(len(m) > 2 && m[2] == "next") {
 			continue
diff --git a/script/sign b/script/sign
index 1630a06b5..f07a7d2d4 100755
--- a/script/sign
+++ b/script/sign
@@ -1,36 +1,12 @@
 #!/bin/bash
 # usage: script/sign <file>
 #
-# Signs macOS binaries using codesign, notarizes macOS zip archives using notarytool, and signs
-# Windows EXE and MSI files using osslsigncode.
+# Signs macOS binaries using codesign, notarizes macOS zip archives using notarytool
 #
 set -e
 
-sign_windows() {
-  if [ -z "$CERT_FILE" ]; then
-    echo "skipping Windows code-signing; CERT_FILE not set" >&2
-    return 0
-  fi
-
-  if [ ! -f "$CERT_FILE" ]; then
-    echo "error Windows code-signing; file '$CERT_FILE' not found" >&2
-    return 1
-  fi
-
-  if [ -z "$CERT_PASSWORD" ]; then
-    echo "error Windows code-signing; no value for CERT_PASSWORD" >&2
-    return 1
-  fi
-
-  osslsigncode sign -n "GitHub CLI" -t http://timestamp.digicert.com \
-    -pkcs12 "$CERT_FILE" -readpass <(printf "%s" "$CERT_PASSWORD") -h sha256 \
-    -in "$1" -out "$1"~
-
-  mv "$1"~ "$1"
-}
-
 sign_macos() {
-  if [ -z "$APPLE_DEVELOPER_ID" ]; then
+  if [[ -z "$APPLE_DEVELOPER_ID" ]]; then
     echo "skipping macOS code-signing; APPLE_DEVELOPER_ID not set" >&2
     return 0
   fi
@@ -42,24 +18,17 @@ sign_macos() {
   fi
 }
 
-if [ $# -eq 0 ]; then
+if [[ $# -eq 0 ]]; then
   echo "usage: script/sign <file>" >&2
   exit 1
 fi
 
 platform="$(uname -s)"
+if [[ $platform != "Darwin" ]]; then
+  echo "error: must run on macOS; skipping codesigning/notarization" >&2
+  exit 1
+fi
 
 for input_file; do
-  case "$input_file" in
-  *.exe | *.msi )
-    sign_windows "$input_file"
-    ;;
-  * )
-    if [ "$platform" = "Darwin" ]; then
-      sign_macos "$input_file"
-    else
-      printf "warning: don't know how to sign %s on %s\n" "$1", "$platform" >&2
-    fi
-    ;;
-  esac
-done
\ No newline at end of file
+  sign_macos "$input_file"
+done



Test patch:
diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
index b22929ed5..b8eff8389 100644
--- a/acceptance/acceptance_test.go
+++ b/acceptance/acceptance_test.go
@@ -69,7 +69,7 @@ func TestIssues(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
+	testscript.Run(t, testScriptParamsFor(tsEnv, "issue"))
 }
 
 func TestLabels(t *testing.T) {
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar
new file mode 100644
index 000000000..a30286ce3
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar
@@ -0,0 +1,31 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Create an issue in the repo
+cd ${REPO}
+exec gh issue create --title 'Feature Request' --body 'Feature Body'
+stdout2env ISSUE_URL
+
+# Comment on the issue
+exec gh issue comment ${ISSUE_URL} --body 'Looks like a great feature!'
+
+# View the issue
+exec gh issue view ${ISSUE_URL} --comments
+stdout 'Looks like a great feature!'
+
+# Edit the last comment
+exec gh issue comment ${ISSUE_URL} --edit-last --body 'Looks like an amazing feature!'
+
+# View the issue
+exec gh issue view ${ISSUE_URL} --comments
+! stdout 'Looks like a great feature!'
+stdout 'Looks like an amazing feature!'
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar
new file mode 100644
index 000000000..44532680b
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar
@@ -0,0 +1,23 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Create an issue in the repo
+cd ${REPO}
+exec gh issue create --title 'Feature Request' --body 'Feature Body'
+stdout2env ISSUE_URL
+
+# Comment on the issue
+exec gh issue comment ${ISSUE_URL} --edit-last --create-if-none --body 'Looks like a great feature!'
+
+# View the issue
+exec gh issue view ${ISSUE_URL} --comments
+stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar
new file mode 100644
index 000000000..300131180
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar
@@ -0,0 +1,20 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Create an issue in the repo
+cd ${REPO}
+exec gh issue create --title 'Feature Request' --body 'Feature Body'
+stdout2env ISSUE_URL
+
+# Comment on the issue
+! exec gh issue comment ${ISSUE_URL} --edit-last --body 'Looks like a great feature!'
+stderr 'no comments found for current user'
diff --git a/acceptance/testdata/issue/issue-comment-new.txtar b/acceptance/testdata/issue/issue-comment-new.txtar
new file mode 100644
index 000000000..12524b6d5
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-new.txtar
@@ -0,0 +1,23 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Create an issue in the repo
+cd ${REPO}
+exec gh issue create --title 'Feature Request' --body 'Feature Body'
+stdout2env ISSUE_URL
+
+# Comment on the issue
+exec gh issue comment ${ISSUE_URL} --body 'Looks like a great feature!'
+
+# View the issue
+exec gh issue view ${ISSUE_URL} --comments
+stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/issue/issue-comment.txtar b/acceptance/testdata/issue/issue-comment.txtar
deleted file mode 100644
index f47bf619c..000000000
--- a/acceptance/testdata/issue/issue-comment.txtar
+++ /dev/null
@@ -1,20 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body'
-stdout2env ISSUE_URL
-
-# Comment on the issue
-exec gh issue comment $ISSUE_URL --body 'Looks like a great feature!'
-
-# View the issue
-exec gh issue view $ISSUE_URL --comments
-stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/issue/issue-create-edit-with-project.txtar b/acceptance/testdata/issue/issue-create-edit-with-project.txtar
new file mode 100644
index 000000000..568530cb9
--- /dev/null
+++ b/acceptance/testdata/issue/issue-create-edit-with-project.txtar
@@ -0,0 +1,43 @@
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a project
+env PROJECT_TITLE=${REPO}-project
+exec gh project create --owner=${ORG} --title=${PROJECT_TITLE} --format='json' --jq='.number'
+stdout2env PROJECT_NUMBER
+
+defer gh project delete --owner=${ORG} ${PROJECT_NUMBER}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Create an issue in the repo
+cd ${REPO}
+exec gh issue create --title 'Feature Request' --body 'Feature Body' --project ${PROJECT_TITLE}
+stdout2env ISSUE_URL
+
+# Check that default issue view is working
+exec gh issue view ${ISSUE_URL}
+
+# Check the issue was added to the project
+exec gh issue view ${ISSUE_URL} --json projectItems --jq '.projectItems[0].title'
+stdout ${PROJECT_TITLE}
+
+# Remove the issue from the project
+exec gh issue edit ${ISSUE_URL} --remove-project ${PROJECT_TITLE}
+
+# Check the issue was removed from the project
+exec gh issue view ${ISSUE_URL} --json projectItems --jq '.projectItems[0].title'
+! stdout ${PROJECT_TITLE}
+
+# Re add the issue to the project
+exec gh issue edit ${ISSUE_URL} --add-project ${PROJECT_TITLE}
+
+# Check the issue was added to the project
+exec gh issue view ${ISSUE_URL} --json projectItems --jq '.projectItems[0].title'
+stdout ${PROJECT_TITLE}
diff --git a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar
index 9a0494f4b..637422a5a 100644
--- a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar
+++ b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar
@@ -12,6 +12,7 @@ defer gh repo delete --yes ${ORG}/${REPO}
 
 # Create a fork
 exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork
+sleep 5
 
 # Defer fork cleanup
 defer gh repo delete --yes ${ORG}/${REPO}-fork
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar
new file mode 100644
index 000000000..be4650ffa
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar
@@ -0,0 +1,40 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Prepare a branch to PR
+cd ${REPO}
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Comment on the PR
+exec gh pr comment ${PR_URL} --body 'Looks like a great feature!'
+
+# View the PR
+exec gh pr view ${PR_URL} --comments
+stdout 'Looks like a great feature!'
+
+# Edit the last comment
+exec gh pr comment ${PR_URL} --edit-last --body 'Looks like an amazing feature!'
+
+# View the PR
+exec gh pr view ${PR_URL} --comments
+! stdout 'Looks like a great feature!'
+stdout 'Looks like an amazing feature!'
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar
new file mode 100644
index 000000000..e6205737f
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar
@@ -0,0 +1,32 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Prepare a branch to PR
+cd ${REPO}
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Comment on the PR
+exec gh pr comment ${PR_URL} --edit-last --create-if-none --body 'Looks like a great feature!'
+
+# View the PR
+exec gh pr view ${PR_URL} --comments
+stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar
new file mode 100644
index 000000000..3a70adb72
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar
@@ -0,0 +1,29 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Prepare a branch to PR
+cd ${REPO}
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Comment on the PR
+! exec gh pr comment ${PR_URL} --edit-last --body 'Looks like a great feature!'
+stderr 'no comments found for current user'
diff --git a/acceptance/testdata/pr/pr-comment.txtar b/acceptance/testdata/pr/pr-comment-new.txtar
similarity index 58%
rename from acceptance/testdata/pr/pr-comment.txtar
rename to acceptance/testdata/pr/pr-comment-new.txtar
index 2c4d488b5..c5b80314c 100644
--- a/acceptance/testdata/pr/pr-comment.txtar
+++ b/acceptance/testdata/pr/pr-comment-new.txtar
@@ -1,17 +1,20 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
 # Use gh as a credential helper
 exec gh auth setup-git
 
 # Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+exec gh repo create ${ORG}/${REPO} --add-readme --private
 
 # Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+defer gh repo delete --yes ${ORG}/${REPO}
 
 # Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+exec gh repo clone ${ORG}/${REPO}
 
 # Prepare a branch to PR
-cd $SCRIPT_NAME-$RANDOM_STRING
+cd ${REPO}
 exec git checkout -b feature-branch
 exec git commit --allow-empty -m 'Empty Commit'
 exec git push -u origin feature-branch
@@ -21,8 +24,8 @@ exec gh pr create --title 'Feature Title' --body 'Feature Body'
 stdout2env PR_URL
 
 # Comment on the PR
-exec gh pr comment $PR_URL --body 'Looks like a great feature!'
+exec gh pr comment ${PR_URL} --body 'Looks like a great feature!'
 
 # View the PR
-exec gh pr view $PR_URL --comments
+exec gh pr view ${PR_URL} --comments
 stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/pr/pr-create-edit-with-project.txtar b/acceptance/testdata/pr/pr-create-edit-with-project.txtar
new file mode 100644
index 000000000..9850313f0
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-edit-with-project.txtar
@@ -0,0 +1,51 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a project
+env PROJECT_TITLE=${REPO}-project
+exec gh project create --owner=${ORG} --title=${PROJECT_TITLE} --format='json' --jq='.number'
+stdout2env PROJECT_NUMBER
+
+defer gh project delete --owner=${ORG} ${PROJECT_NUMBER}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Prepare a branch to PR
+cd ${REPO}
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body' --project ${PROJECT_TITLE}
+stdout2env PR_URL
+
+# Check that default pr view is working
+exec gh pr view ${PR_URL}
+
+# Check the pr was added to the project
+exec gh pr view ${PR_URL} --json projectItems --jq '.projectItems[0].title'
+stdout ${PROJECT_TITLE}
+
+# Remove the pr from the project
+exec gh pr edit ${PR_URL} --remove-project ${PROJECT_TITLE}
+
+# Check the pr was removed from the project
+exec gh pr view ${PR_URL} --json projectItems --jq '.projectItems[0].title'
+! stdout ${PROJECT_TITLE}
+
+# Re add the pr to the project
+exec gh pr edit ${PR_URL} --add-project ${PROJECT_TITLE}
+
+# Check the pr was added to the project
+exec gh pr view ${PR_URL} --json projectItems --jq '.projectItems[0].title'
+stdout ${PROJECT_TITLE}
diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar
new file mode 100644
index 000000000..542579b0a
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar
@@ -0,0 +1,50 @@
+skip 'it creates a fork owned by the user running the test'
+
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch to commit
+exec git checkout -b feature/branch
+exec git commit --allow-empty -m 'Upstream Commit'
+# Push without setting an upstream (-u or config)
+exec git push upstream feature/branch
+
+# Prepare an additional commit
+exec git commit --allow-empty -m 'Fork Commit'
+# Push without setting an upstream (-u or config)
+exec git push origin feature/branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Check the PR is indeed created
+exec gh pr view ${USER}:feature/branch --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar
new file mode 100644
index 000000000..e263b0351
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar
@@ -0,0 +1,48 @@
+skip 'it creates a fork owned by the user running the test'
+
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch to commit
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Upstream Commit'
+exec git push upstream feature-branch
+
+# Prepare an additional commit
+exec git commit --allow-empty -m 'Fork Commit'
+exec git push origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Check the PR is indeed created
+exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar
new file mode 100644
index 000000000..cb42d99f8
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar
@@ -0,0 +1,27 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+
+# Prepare a branch to PR
+cd ${REPO}
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Leave the repo so there's no local repo
+cd ${WORK}
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${REPO} --head feature-branch
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
\ No newline at end of file
diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar
new file mode 100644
index 000000000..0974f9225
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar
@@ -0,0 +1,50 @@
+skip 'it creates a fork owned by the user running the test'
+skip 'this never worked, but could be fixed if we fixed show-refs'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Configure push.default so that it should use the merge ref
+exec git config push.default upstream
+
+# But prepare a branch that doesn't have a tracking merge ref
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar
new file mode 100644
index 000000000..90c5cde50
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar
@@ -0,0 +1,33 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+cd ${REPO}
+
+# Configure push.default so that it should use the merge ref
+exec git config push.default upstream
+
+# But prepare a branch that doesn't have a tracking merge ref
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
diff --git a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar
new file mode 100644
index 000000000..395fce86a
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar
@@ -0,0 +1,46 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
+exec git checkout -b feature/branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature/branch
+
+# Create the PR spanning upstream and fork repositories
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar
new file mode 100644
index 000000000..e0d0c099c
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar
@@ -0,0 +1,49 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to upstream/main
+exec git config branch.feature-branch.pushRemote origin
+exec git config unset remote.upstream.gh-resolved
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push
+
+# Create the PR spanning upstream and fork repositories
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar
new file mode 100644
index 000000000..51708405d
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar
@@ -0,0 +1,53 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Configure default push behavior so local and remote branches will be the same
+exec git config push.default current
+
+# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to origin/main
+exec git rev-parse --abbrev-ref feature-branch@{upstream}
+stdout origin/main
+exec git config unset remote.upstream.gh-resolved
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar
new file mode 100644
index 000000000..ff92f1e2d
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar
@@ -0,0 +1,49 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to upstream/main
+exec git config remote.pushDefault origin
+exec git config unset remote.upstream.gh-resolved
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push
+
+# Create the PR spanning upstream and fork repositories
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar
new file mode 100644
index 000000000..63d3ae2b4
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar
@@ -0,0 +1,34 @@
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of repo
+defer gh repo delete --yes ${ORG}/${REPO}
+exec gh repo view ${ORG}/${REPO} --json id --jq '.id'
+stdout2env REPO_ID
+
+# Clone the repo
+exec gh repo clone ${ORG}/${REPO}
+cd ${REPO}
+
+# Configure default push behavior so local and remote branches have to be the same
+exec git config push.default simple
+
+# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to origin/main
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false}
diff --git a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar
new file mode 100644
index 000000000..a59171d58
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar
@@ -0,0 +1,47 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the fork
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to upstream/main
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push origin feature-branch
+
+# Create the PR spanning upstream and fork repositories
+exec gh pr create --title 'Feature Title' --body 'Feature Body' --head ${USER}:feature-branch
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository
+stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}
diff --git a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar
index 00f3535a7..e5a40af72 100644
--- a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar
+++ b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar
@@ -1,20 +1,22 @@
 # This test is the same as pr-create-basic, except that the git push doesn't include the -u argument
 # This causes a git config read to fail during gh pr create, but it should not be fatal
 
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
 # Use gh as a credential helper
 exec gh auth setup-git
 
 # Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+exec gh repo create ${ORG}/${REPO} --add-readme --private
 
 # Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+defer gh repo delete --yes ${ORG}/${REPO}
 
 # Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+exec gh repo clone ${ORG}/${REPO}
 
 # Prepare a branch to PR
-cd $SCRIPT_NAME-$RANDOM_STRING
+cd ${REPO}
 exec git checkout -b feature-branch
 exec git commit --allow-empty -m 'Empty Commit'
 exec git push origin feature-branch
diff --git a/acceptance/testdata/pr/pr-status-respects-cross-org.txtar b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar
new file mode 100644
index 000000000..4505be923
--- /dev/null
+++ b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar
@@ -0,0 +1,46 @@
+skip 'it creates a fork owned by the user running the test'
+
+# Setup environment variables used for testscript
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+env FORK=${REPO}-fork
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Get the current username for the fork owner
+exec gh api user --jq .login
+stdout2env USER
+
+# Create a repository to act as upstream with a file so it has a default branch
+exec gh repo create ${ORG}/${REPO} --add-readme --private
+
+# Defer repo cleanup of upstream
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Create a user fork of repository. This will be owned by USER.
+exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
+sleep 5
+
+# Defer repo cleanup of fork
+defer gh repo delete --yes ${USER}/${FORK}
+
+# Retrieve fork repository information
+exec gh repo view ${USER}/${FORK} --json id --jq '.id'
+stdout2env FORK_ID
+
+# Clone the repo
+exec gh repo clone ${USER}/${FORK}
+cd ${FORK}
+
+# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR spanning upstream and fork repositories
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
+
+# Assert that the PR was created with the correct head repository and refs
+exec gh pr status
+! stdout 'There is no pull request associated with'
diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar
index ca58918a9..eed524dec 100644
--- a/acceptance/testdata/pr/pr-view-same-org-fork.txtar
+++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar
@@ -15,10 +15,11 @@ stdout2env REPO_ID
 
 # Create a fork in the same org
 exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
+sleep 5
 
 # Defer repo cleanup of fork
 defer gh repo delete --yes ${ORG}/${FORK}
-sleep 1
+
 exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
 stdout2env FORK_ID
 
diff --git a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar
index ef80cd8ba..4e1e5e64a 100644
--- a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar
+++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar
@@ -15,10 +15,11 @@ stdout2env REPO_ID
 
 # Create a user fork of repository as opposed to private organization fork
 exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
+sleep 5
 
 # Defer repo cleanup of fork
 defer gh repo delete --yes ${ORG}/${FORK}
-sleep 5
+
 exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
 stdout2env FORK_ID
 
@@ -27,7 +28,8 @@ exec gh repo clone ${ORG}/${FORK}
 cd ${FORK}
 
 # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
-exec git checkout -b feature-branch upstream/main
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to upstream/main
 exec git config branch.feature-branch.pushRemote origin
 exec git commit --allow-empty -m 'Empty Commit'
 exec git push
diff --git a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar
index 8bfac2837..6c0743a6f 100644
--- a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar
+++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar
@@ -15,10 +15,11 @@ stdout2env REPO_ID
 
 # Create a user fork of repository as opposed to private organization fork
 exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
+sleep 5
 
 # Defer repo cleanup of fork
 defer gh repo delete --yes ${ORG}/${FORK}
-sleep 5
+
 exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
 stdout2env FORK_ID
 
@@ -27,7 +28,8 @@ exec gh repo clone ${ORG}/${FORK}
 cd ${FORK}
 
 # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
-exec git checkout -b feature-branch upstream/main
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to upstream/main
 exec git config remote.pushDefault origin
 exec git commit --allow-empty -m 'Empty Commit'
 exec git push
diff --git a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar
index 114f401ec..b9621ea72 100644
--- a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar
+++ b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar
@@ -18,7 +18,8 @@ cd ${REPO}
 exec git config push.default simple
 
 # Prepare a branch where changes are pulled from the default branch instead of remote branch of same name
-exec git checkout -b feature-branch origin/main
+exec git checkout -b feature-branch
+exec git branch --set-upstream-to origin/main
 
 # Create the PR
 exec git commit --allow-empty -m 'Empty Commit'
diff --git a/acceptance/testdata/repo/repo-fork-sync.txtar b/acceptance/testdata/repo/repo-fork-sync.txtar
index 6ed7b94e1..04c4c5845 100644
--- a/acceptance/testdata/repo/repo-fork-sync.txtar
+++ b/acceptance/testdata/repo/repo-fork-sync.txtar
@@ -9,13 +9,11 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
 
 # Fork and clone the repo
 exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone
+sleep 5
 
 # Defer fork cleanup
 defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes
 
-# Sleep so that the BE has time to sync
-sleep 5
-
 # Check that the repo was forked
 exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork'
 stdout 'true'
diff --git a/acceptance/testdata/repo/repo-set-default.txtar b/acceptance/testdata/repo/repo-set-default.txtar
index 4f7fa3273..de4eda11f 100644
--- a/acceptance/testdata/repo/repo-set-default.txtar
+++ b/acceptance/testdata/repo/repo-set-default.txtar
@@ -7,7 +7,7 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
 # Ensure that no default is set
 cd $SCRIPT_NAME-$RANDOM_STRING
 exec gh repo set-default --view
-stderr 'no default repository has been set; use `gh repo set-default` to select one'
+stderr 'No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help'
 
 # Set the default
 exec gh repo set-default $ORG/$SCRIPT_NAME-$RANDOM_STRING
diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
index f4d1bbb4a..f3fa4a47a 100644
--- a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
+++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
@@ -12,13 +12,11 @@ defer gh repo delete --yes ${ORG}/${REPO}
 
 # Create a fork
 exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork
+sleep 5
 
 # Defer fork cleanup
 defer gh repo delete --yes ${ORG}/${REPO}-fork
 
-# Sleep to allow the fork to be created before cloning
-sleep 2
-
 # Clone and move into the fork repo
 exec gh repo clone ${ORG}/${REPO}-fork
 cd ${REPO}-fork
@@ -34,3 +32,29 @@ stderr 'multiple remotes detected. please specify which repo to use by providing
 # Secret delete requires disambiguation
 ! exec gh secret delete 'TEST_SECRET_NAME'
 stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument'
+
+# Move out of the fork repo to test whether secret commands work without local repository context
+cd ..
+
+# Secret set using --repo flag does not require disambiguation
+exec gh secret set 'TEST_SECRET_NAME' --body 'TEST_SECRET_VALUE' --repo ${ORG}/${REPO}-fork
+
+# Secret list using --repo flag does not require disambiguation
+exec gh secret list --repo ${ORG}/${REPO}-fork
+stdout 'TEST_SECRET_NAME'
+
+# Secret delete using --repo flag does not require disambiguation
+exec gh secret delete 'TEST_SECRET_NAME' --repo ${ORG}/${REPO}-fork
+
+# Setup GH_REPO for testing environment variable behavior
+env GH_REPO=${ORG}/${REPO}-fork
+
+# Secret set using GH_REPO env var does not require disambiguation
+exec gh secret set 'TEST_SECRET_NAME2' --body 'TEST_SECRET_VALUE2'
+
+# Secret list using GH_REPO env var does not require disambiguation
+exec gh secret list
+stdout 'TEST_SECRET_NAME2'
+
+# Secret delete using GH_REPO env var does not require disambiguation
+exec gh secret delete 'TEST_SECRET_NAME2'
diff --git a/api/export_pr_test.go b/api/export_pr_test.go
index b7f4dcddb..09a1dffe8 100644
--- a/api/export_pr_test.go
+++ b/api/export_pr_test.go
@@ -245,6 +245,70 @@ func TestPullRequest_ExportData(t *testing.T) {
 				}
 			`),
 		},
+		{
+			name:   "linked issues",
+			fields: []string{"closingIssuesReferences"},
+			inputJSON: heredoc.Doc(`
+				{ "closingIssuesReferences": { "nodes": [
+					{
+						"id": "I_123",
+						"number": 123,
+						"url": "https://github.com/cli/cli/issues/123",
+						"repository": {
+							"id": "R_123",
+							"name": "cli",
+							"owner": {
+								"id": "O_123",
+								"login": "cli"
+							}
+						}
+					},
+					{
+						"id": "I_456",
+						"number": 456,
+						"url": "https://github.com/cli/cli/issues/456",
+						"repository": {
+							"id": "R_456",
+							"name": "cli",
+							"owner": {
+								"id": "O_456",
+								"login": "cli"
+							}
+						}
+					}
+				] } }
+			`),
+			outputJSON: heredoc.Doc(`
+				{ "closingIssuesReferences": [
+					{
+						"id": "I_123",
+						"number": 123,
+						"repository": {
+							"id": "R_123",
+							"name": "cli",
+							"owner": {
+							"id": "O_123",
+							"login": "cli"
+							}
+						},
+						"url": "https://github.com/cli/cli/issues/123"
+					},
+					{
+						"id": "I_456",
+						"number": 456,
+						"repository": {
+							"id": "R_456",
+							"name": "cli",
+							"owner": {
+							"id": "O_456",
+							"login": "cli"
+							}
+						},
+						"url": "https://github.com/cli/cli/issues/456"
+					}
+				] }
+			`),
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go
index 13aee459a..72ed35776 100644
--- a/api/queries_repo_test.go
+++ b/api/queries_repo_test.go
@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/stretchr/testify/assert"
@@ -44,7 +45,8 @@ func Test_RepoMetadata(t *testing.T) {
 		Assignees:  true,
 		Reviewers:  true,
 		Labels:     true,
-		Projects:   true,
+		ProjectsV1: true,
+		ProjectsV2: true,
 		Milestones: true,
 	}
 
@@ -211,37 +213,16 @@ func Test_RepoMetadata(t *testing.T) {
 	}
 }
 
-func Test_ProjectsToPaths(t *testing.T) {
-	expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER", "OWNER/REPO/PROJECT_NUMBER_2"}
-	projects := []RepoProject{
-		{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
-		{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"},
-		{ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"},
-	}
-	projectsV2 := []ProjectV2{
-		{ID: "id4", Title: "My Project V2", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER_2"},
-		{ID: "id5", Title: "Org Project V2", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_3"},
-	}
-	projectNames := []string{"My Project", "Org Project", "My Project V2"}
-
-	projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames)
-	if err != nil {
-		t.Errorf("error resolving projects: %v", err)
-	}
-	if !sliceEqual(projectPaths, expectedProjectPaths) {
-		t.Errorf("expected projects %v, got %v", expectedProjectPaths, projectPaths)
-	}
-}
-
 func Test_ProjectNamesToPaths(t *testing.T) {
-	http := &httpmock.Registry{}
-	client := newTestClient(http)
+	t.Run("when projectsV1 is supported, requests them", func(t *testing.T) {
+		http := &httpmock.Registry{}
+		client := newTestClient(http)
 
-	repo, _ := ghrepo.FromFullName("OWNER/REPO")
+		repo, _ := ghrepo.FromFullName("OWNER/REPO")
 
-	http.Register(
-		httpmock.GraphQL(`query RepositoryProjectList\b`),
-		httpmock.StringResponse(`
+		http.Register(
+			httpmock.GraphQL(`query RepositoryProjectList\b`),
+			httpmock.StringResponse(`
 		{ "data": { "repository": { "projects": {
 			"nodes": [
 				{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" },
@@ -250,9 +231,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
 			"pageInfo": { "hasNextPage": false }
 		} } } }
 		`))
-	http.Register(
-		httpmock.GraphQL(`query OrganizationProjectList\b`),
-		httpmock.StringResponse(`
+		http.Register(
+			httpmock.GraphQL(`query OrganizationProjectList\b`),
+			httpmock.StringResponse(`
 		{ "data": { "organization": { "projects": {
 			"nodes": [
 				{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1"  }
@@ -260,9 +241,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
 			"pageInfo": { "hasNextPage": false }
 		} } } }
 		`))
-	http.Register(
-		httpmock.GraphQL(`query RepositoryProjectV2List\b`),
-		httpmock.StringResponse(`
+		http.Register(
+			httpmock.GraphQL(`query RepositoryProjectV2List\b`),
+			httpmock.StringResponse(`
 		{ "data": { "repository": { "projectsV2": {
 			"nodes": [
 				{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" },
@@ -271,9 +252,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
 			"pageInfo": { "hasNextPage": false }
 		} } } }
 		`))
-	http.Register(
-		httpmock.GraphQL(`query OrganizationProjectV2List\b`),
-		httpmock.StringResponse(`
+		http.Register(
+			httpmock.GraphQL(`query OrganizationProjectV2List\b`),
+			httpmock.StringResponse(`
 		{ "data": { "organization": { "projectsV2": {
 			"nodes": [
 				{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2"  }
@@ -281,9 +262,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
 			"pageInfo": { "hasNextPage": false }
 		} } } }
 		`))
-	http.Register(
-		httpmock.GraphQL(`query UserProjectV2List\b`),
-		httpmock.StringResponse(`
+		http.Register(
+			httpmock.GraphQL(`query UserProjectV2List\b`),
+			httpmock.StringResponse(`
 		{ "data": { "viewer": { "projectsV2": {
 			"nodes": [
 				{ "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5"  }
@@ -292,15 +273,110 @@ func Test_ProjectNamesToPaths(t *testing.T) {
 		} } } }
 		`))
 
-	projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"})
-	if err != nil {
-		t.Fatalf("unexpected error: %v", err)
-	}
+		projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
 
-	expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"}
-	if !sliceEqual(projectPaths, expectedProjectPaths) {
-		t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
-	}
+		expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"}
+		if !sliceEqual(projectPaths, expectedProjectPaths) {
+			t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
+		}
+	})
+
+	t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) {
+		http := &httpmock.Registry{}
+		client := newTestClient(http)
+
+		repo, _ := ghrepo.FromFullName("OWNER/REPO")
+
+		http.Exclude(
+			t,
+			httpmock.GraphQL(`query RepositoryProjectList\b`),
+		)
+		http.Exclude(
+			t,
+			httpmock.GraphQL(`query OrganizationProjectList\b`),
+		)
+
+		http.Register(
+			httpmock.GraphQL(`query RepositoryProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "repository": { "projectsV2": {
+			"nodes": [
+				{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" },
+				{ "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" }
+			],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		http.Register(
+			httpmock.GraphQL(`query OrganizationProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "organization": { "projectsV2": {
+			"nodes": [
+				{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2"  }
+			],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		http.Register(
+			httpmock.GraphQL(`query UserProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "viewer": { "projectsV2": {
+			"nodes": [
+				{ "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5"  }
+			],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+
+		projectPaths, err := ProjectNamesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+
+		expectedProjectPaths := []string{"ORG/2", "OWNER/REPO/4", "MONALISA/5"}
+		if !sliceEqual(projectPaths, expectedProjectPaths) {
+			t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
+		}
+	})
+
+	t.Run("when a project is not found, returns an error", func(t *testing.T) {
+		http := &httpmock.Registry{}
+		client := newTestClient(http)
+
+		repo, _ := ghrepo.FromFullName("OWNER/REPO")
+
+		// No projects found
+		http.Register(
+			httpmock.GraphQL(`query RepositoryProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "repository": { "projectsV2": {
+			"nodes": [],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		http.Register(
+			httpmock.GraphQL(`query OrganizationProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "organization": { "projectsV2": {
+			"nodes": [],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		http.Register(
+			httpmock.GraphQL(`query UserProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "viewer": { "projectsV2": {
+			"nodes": [],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+
+		_, err := ProjectNamesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported)
+		require.Equal(t, err, fmt.Errorf("'TriageV2' not found"))
+	})
 }
 
 func Test_RepoResolveMetadataIDs(t *testing.T) {
diff --git a/git/client_test.go b/git/client_test.go
index 9fa076199..f59b26077 100644
--- a/git/client_test.go
+++ b/git/client_test.go
@@ -952,7 +952,7 @@ func TestClientPushDefault(t *testing.T) {
 	tests := []struct {
 		name            string
 		commandResult   commandResult
-		wantPushDefault string
+		wantPushDefault PushDefault
 		wantError       *GitError
 	}{
 		{
@@ -961,7 +961,7 @@ func TestClientPushDefault(t *testing.T) {
 				ExitStatus: 1,
 				Stderr:     "error: key does not contain a section: remote.pushDefault",
 			},
-			wantPushDefault: "simple",
+			wantPushDefault: PushDefaultSimple,
 			wantError:       nil,
 		},
 		{
@@ -970,7 +970,7 @@ func TestClientPushDefault(t *testing.T) {
 				ExitStatus: 0,
 				Stdout:     "current",
 			},
-			wantPushDefault: "current",
+			wantPushDefault: PushDefaultCurrent,
 			wantError:       nil,
 		},
 		{
@@ -1077,17 +1077,17 @@ func TestClientParsePushRevision(t *testing.T) {
 		name                   string
 		branch                 string
 		commandResult          commandResult
-		wantParsedPushRevision string
-		wantError              *GitError
+		wantParsedPushRevision RemoteTrackingRef
+		wantError              error
 	}{
 		{
-			name:   "@{push} resolves to origin/branchName",
+			name:   "@{push} resolves to refs/remotes/origin/branchName",
 			branch: "branchName",
 			commandResult: commandResult{
 				ExitStatus: 0,
-				Stdout:     "origin/branchName",
+				Stdout:     "refs/remotes/origin/branchName",
 			},
-			wantParsedPushRevision: "origin/branchName",
+			wantParsedPushRevision: RemoteTrackingRef{Remote: "origin", Branch: "branchName"},
 		},
 		{
 			name: "@{push} doesn't resolve",
@@ -1095,16 +1095,25 @@ func TestClientParsePushRevision(t *testing.T) {
 				ExitStatus: 128,
 				Stderr:     "fatal: git error",
 			},
-			wantParsedPushRevision: "",
+			wantParsedPushRevision: RemoteTrackingRef{},
 			wantError: &GitError{
 				ExitCode: 128,
 				Stderr:   "fatal: git error",
 			},
 		},
+		{
+			name: "@{push} resolves to something surprising",
+			commandResult: commandResult{
+				ExitStatus: 0,
+				Stdout:     "not/a/valid/remote/ref",
+			},
+			wantParsedPushRevision: RemoteTrackingRef{},
+			wantError:              fmt.Errorf("could not parse push revision: remote tracking branch must have format refs/remotes/<remote>/<branch> but was: not/a/valid/remote/ref"),
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			cmd := fmt.Sprintf("path/to/git rev-parse --abbrev-ref %s@{push}", tt.branch)
+			cmd := fmt.Sprintf("path/to/git rev-parse --symbolic-full-name %s@{push}", tt.branch)
 			cmdCtx := createMockedCommandContext(t, mockedCommands{
 				args(cmd): tt.commandResult,
 			})
@@ -1112,20 +1121,116 @@ func TestClientParsePushRevision(t *testing.T) {
 				GitPath:        "path/to/git",
 				commandContext: cmdCtx,
 			}
-			pushDefault, err := client.ParsePushRevision(context.Background(), tt.branch)
+			trackingRef, err := client.PushRevision(context.Background(), tt.branch)
 			if tt.wantError != nil {
-				var gitError *GitError
-				require.ErrorAs(t, err, &gitError)
-				assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
-				assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
+				var wantErrorAsGit *GitError
+				if errors.As(err, &wantErrorAsGit) {
+					var gitError *GitError
+					require.ErrorAs(t, err, &gitError)
+					assert.Equal(t, wantErrorAsGit.ExitCode, gitError.ExitCode)
+					assert.Equal(t, wantErrorAsGit.Stderr, gitError.Stderr)
+				} else {
+					assert.Equal(t, err, tt.wantError)
+				}
 			} else {
 				require.NoError(t, err)
 			}
-			assert.Equal(t, tt.wantParsedPushRevision, pushDefault)
+			assert.Equal(t, tt.wantParsedPushRevision, trackingRef)
 		})
 	}
 }
 
+func TestRemoteTrackingRef(t *testing.T) {
+	t.Run("parsing", func(t *testing.T) {
+		t.Parallel()
+
+		tests := []struct {
+			name                  string
+			remoteTrackingRef     string
+			wantRemoteTrackingRef RemoteTrackingRef
+			wantError             error
+		}{
+			{
+				name:              "valid remote tracking ref without slash in branch name",
+				remoteTrackingRef: "refs/remotes/origin/branchName",
+				wantRemoteTrackingRef: RemoteTrackingRef{
+					Remote: "origin",
+					Branch: "branchName",
+				},
+			},
+			{
+				name:              "valid remote tracking ref with slash in branch name",
+				remoteTrackingRef: "refs/remotes/origin/branch/name",
+				wantRemoteTrackingRef: RemoteTrackingRef{
+					Remote: "origin",
+					Branch: "branch/name",
+				},
+			},
+			// TODO: Uncomment when we support slashes in remote names
+			// {
+			// 	name: "valid remote tracking ref with slash in remote name",
+			// 	remoteTrackingRef: "refs/remotes/my/origin/branchName",
+			// 	wantRemoteTrackingRef: RemoteTrackingRef{
+			// 		Remote: "my/origin",
+			// 		Branch: "branchName",
+			// 	},
+			// },
+			// {
+			// 	name: 			"valid remote tracking ref with slash in remote name and branch name",
+			// 	remoteTrackingRef: "refs/remotes/my/origin/branch/name",
+			// 	wantRemoteTrackingRef: RemoteTrackingRef{
+			// 		Remote: "my/origin",
+			// 		Branch: "branch/name",
+			// 	},
+			// },
+			{
+				name:                  "incorrect parts",
+				remoteTrackingRef:     "refs/remotes/origin",
+				wantRemoteTrackingRef: RemoteTrackingRef{},
+				wantError:             fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: refs/remotes/origin"),
+			},
+			{
+				name:                  "incorrect prefix type",
+				remoteTrackingRef:     "invalid/remotes/origin/branchName",
+				wantRemoteTrackingRef: RemoteTrackingRef{},
+				wantError:             fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: invalid/remotes/origin/branchName"),
+			},
+			{
+				name:                  "incorrect ref type",
+				remoteTrackingRef:     "refs/invalid/origin/branchName",
+				wantRemoteTrackingRef: RemoteTrackingRef{},
+				wantError:             fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: refs/invalid/origin/branchName"),
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				t.Parallel()
+
+				trackingRef, err := ParseRemoteTrackingRef(tt.remoteTrackingRef)
+				if tt.wantError != nil {
+					require.Equal(t, tt.wantError, err)
+					return
+				}
+
+				require.NoError(t, err)
+				assert.Equal(t, tt.wantRemoteTrackingRef, trackingRef)
+			})
+		}
+	})
+
+	t.Run("stringifying", func(t *testing.T) {
+		t.Parallel()
+
+		remoteTrackingRef := RemoteTrackingRef{
+			Remote: "origin",
+			Branch: "branchName",
+		}
+
+		require.Equal(t, "refs/remotes/origin/branchName", remoteTrackingRef.String())
+	})
+}
+
 func TestClientDeleteLocalTag(t *testing.T) {
 	tests := []struct {
 		name          string
@@ -1992,6 +2097,41 @@ func TestCredentialPatternFromHost(t *testing.T) {
 	}
 }
 
+func TestPushDefault(t *testing.T) {
+	t.Run("it parses valid values correctly", func(t *testing.T) {
+		t.Parallel()
+
+		tests := []struct {
+			value               string
+			expectedPushDefault PushDefault
+		}{
+			{"nothing", PushDefaultNothing},
+			{"current", PushDefaultCurrent},
+			{"upstream", PushDefaultUpstream},
+			{"tracking", PushDefaultTracking},
+			{"simple", PushDefaultSimple},
+			{"matching", PushDefaultMatching},
+		}
+
+		for _, test := range tests {
+			t.Run(test.value, func(t *testing.T) {
+				t.Parallel()
+
+				pushDefault, err := ParsePushDefault(test.value)
+				require.NoError(t, err)
+				assert.Equal(t, test.expectedPushDefault, pushDefault)
+			})
+		}
+	})
+
+	t.Run("it returns an error for invalid values", func(t *testing.T) {
+		t.Parallel()
+
+		_, err := ParsePushDefault("invalid")
+		require.Error(t, err)
+	})
+}
+
 func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) {
 	cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--")
 	cmd.Env = []string{
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index fef87ddc6..67a9a98d1 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -32,6 +32,7 @@ func TestNewConfigProvidesFallback(t *testing.T) {
 	requireKeyWithValue(t, spiedCfg, []string{aliasesKey, "co"}, "pr checkout")
 	requireKeyWithValue(t, spiedCfg, []string{httpUnixSocketKey}, "")
 	requireKeyWithValue(t, spiedCfg, []string{browserKey}, "")
+	requireKeyWithValue(t, spiedCfg, []string{colorLabelsKey}, "disabled")
 }
 
 func TestGetOrDefaultApplicationDefaults(t *testing.T) {
@@ -137,6 +138,7 @@ func TestFallbackConfig(t *testing.T) {
 	requireKeyWithValue(t, cfg, []string{aliasesKey, "co"}, "pr checkout")
 	requireKeyWithValue(t, cfg, []string{httpUnixSocketKey}, "")
 	requireKeyWithValue(t, cfg, []string{browserKey}, "")
+	requireKeyWithValue(t, cfg, []string{colorLabelsKey}, "disabled")
 	requireNoKey(t, cfg, []string{"unknown"})
 }
 
diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go
index 8af091c3f..f1152da2c 100644
--- a/internal/featuredetection/feature_detection_test.go
+++ b/internal/featuredetection/feature_detection_test.go
@@ -5,8 +5,10 @@ import (
 	"testing"
 
 	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestIssueFeatures(t *testing.T) {
@@ -366,3 +368,19 @@ func TestRepositoryFeatures(t *testing.T) {
 		})
 	}
 }
+
+func TestProjectV1Support(t *testing.T) {
+	t.Parallel()
+
+	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)
+	})
+
+	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)
+	})
+}
diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go
new file mode 100644
index 000000000..c95d379e3
--- /dev/null
+++ b/internal/prompter/accessible_prompter_test.go
@@ -0,0 +1,528 @@
+//go:build !windows
+
+package prompter_test
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Netflix/go-expect"
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/creack/pty"
+	"github.com/hinshun/vt10x"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// The following tests are broadly testing the accessible prompter, and NOT asserting
+// on the prompter's complete and exact output strings.
+//
+// These tests generally operate with this logic:
+// - Wait for a particular substring (a portion of the prompt) to appear
+// - Send input
+// - Wait for another substring to appear or for control to return to the test
+// - Assert that the input value was returned from the prompter function
+
+// In the future, expanding these tests to assert on the exact prompt strings
+// would help build confidence in `huh` upgrades, but for now these tests
+// are sufficient to ensure that the accessible prompter behaves roughly as expected
+// but doesn't mandate that prompts always look exactly the same.
+func TestAccessiblePrompter(t *testing.T) {
+	t.Run("Select", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Input a number between 1 and 3:")
+			require.NoError(t, err)
+
+			// Select option 1
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, selectValue)
+	})
+
+	t.Run("MultiSelect", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Input a number between 0 and 3:")
+			require.NoError(t, err)
+
+			// Select options 1 and 2
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// This confirms selections
+			_, err = console.SendLine("0")
+			require.NoError(t, err)
+		}()
+
+		multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, []int{0, 1}, multiSelectValue)
+	})
+
+	t.Run("Input", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		dummyText := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter some characters")
+			require.NoError(t, err)
+
+			// Enter a number
+			_, err = console.SendLine(dummyText)
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.Input("Enter some characters", "")
+		require.NoError(t, err)
+		assert.Equal(t, dummyText, inputValue)
+	})
+
+	t.Run("Input - blank input returns default value", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		dummyDefaultValue := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter some characters")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.Input("Enter some characters", dummyDefaultValue)
+		require.NoError(t, err)
+		assert.Equal(t, dummyDefaultValue, inputValue)
+	})
+
+	t.Run("Password", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		dummyPassword := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Enter password")
+			require.NoError(t, err)
+
+			// Enter a number
+			_, err = console.SendLine(dummyPassword)
+			require.NoError(t, err)
+		}()
+
+		passwordValue, err := p.Password("Enter password")
+		require.NoError(t, err)
+		require.Equal(t, dummyPassword, passwordValue)
+
+		// Ensure the dummy password is not printed to the screen,
+		// asserting that echo mode is disabled.
+		_, err = console.ExpectString(" \r\n\r\n")
+		require.NoError(t, err)
+	})
+
+	t.Run("Confirm", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Are you sure")
+			require.NoError(t, err)
+
+			// Confirm
+			_, err = console.SendLine("y")
+			require.NoError(t, err)
+		}()
+
+		confirmValue, err := p.Confirm("Are you sure", false)
+		require.NoError(t, err)
+		require.Equal(t, true, confirmValue)
+	})
+
+	t.Run("Confirm - blank input returns default", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Are you sure")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		confirmValue, err := p.Confirm("Are you sure", false)
+		require.NoError(t, err)
+		require.Equal(t, false, confirmValue)
+	})
+
+	t.Run("AuthToken", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		dummyAuthToken := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Paste your authentication token:")
+			require.NoError(t, err)
+
+			// Enter some dummy auth token
+			_, err = console.SendLine(dummyAuthToken)
+			require.NoError(t, err)
+		}()
+
+		authValue, err := p.AuthToken()
+		require.NoError(t, err)
+		require.Equal(t, dummyAuthToken, authValue)
+
+		// Ensure the dummy password is not printed to the screen,
+		// asserting that echo mode is disabled.
+		_, err = console.ExpectString(" \r\n\r\n")
+		require.NoError(t, err)
+	})
+
+	t.Run("AuthToken - blank input returns error", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		dummyAuthTokenForAfterFailure := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Paste your authentication token:")
+			require.NoError(t, err)
+
+			// Enter nothing
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+
+			// Expect an error message
+			_, err = console.ExpectString("token is required")
+			require.NoError(t, err)
+
+			// Now enter some dummy auth token to return control back to the test
+			_, err = console.SendLine(dummyAuthTokenForAfterFailure)
+			require.NoError(t, err)
+		}()
+
+		authValue, err := p.AuthToken()
+		require.NoError(t, err)
+		require.Equal(t, dummyAuthTokenForAfterFailure, authValue)
+
+		// Ensure the dummy password is not printed to the screen,
+		// asserting that echo mode is disabled.
+		_, err = console.ExpectString(" \r\n\r\n")
+		require.NoError(t, err)
+	})
+
+	t.Run("ConfirmDeletion", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		requiredValue := "test"
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
+			require.NoError(t, err)
+
+			// Confirm
+			_, err = console.SendLine(requiredValue)
+			require.NoError(t, err)
+		}()
+
+		// An err indicates that the confirmation text sent did not match
+		err := p.ConfirmDeletion(requiredValue)
+		require.NoError(t, err)
+	})
+
+	t.Run("ConfirmDeletion - bad input", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		requiredValue := "test"
+		badInputValue := "garbage"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
+			require.NoError(t, err)
+
+			// Confirm with bad input
+			_, err = console.SendLine(badInputValue)
+			require.NoError(t, err)
+
+			// Expect an error message and loop back to the prompt
+			_, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue))
+			require.NoError(t, err)
+
+			// Confirm with the correct input to return control back to the test
+			_, err = console.SendLine(requiredValue)
+			require.NoError(t, err)
+		}()
+
+		// An err indicates that the confirmation text sent did not match
+		err := p.ConfirmDeletion(requiredValue)
+		require.NoError(t, err)
+	})
+
+	t.Run("InputHostname", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		hostname := "example.com"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Hostname:")
+			require.NoError(t, err)
+
+			// Enter the hostname
+			_, err = console.SendLine(hostname)
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.InputHostname()
+		require.NoError(t, err)
+		require.Equal(t, hostname, inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter 2, to select "skip"
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", "", true)
+		require.NoError(t, err)
+		require.Equal(t, "", inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+		defaultValue := "12345abcdefg"
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter number 2 to select "skip". This shouldn't be allowed.
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// Expect a notice to enter something valid since blank is disallowed.
+			_, err = console.ExpectString("Invalid: must be between 1 and 1")
+			require.NoError(t, err)
+
+			// Send a 1 to select to open the editor. This will immediately exit
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false)
+		require.NoError(t, err)
+		require.Equal(t, defaultValue, inputValue)
+	})
+
+	t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestAccessiblePrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("How to edit?")
+			require.NoError(t, err)
+
+			// Enter number 2 to select "skip". This shouldn't be allowed.
+			_, err = console.SendLine("2")
+			require.NoError(t, err)
+
+			// Expect a notice to enter something valid since blank is disallowed.
+			_, err = console.ExpectString("Invalid: must be between 1 and 1")
+			require.NoError(t, err)
+
+			// Send a 1 to select to open the editor since skip is invalid and
+			// we need to return control back to the test.
+			_, err = console.SendLine("1")
+			require.NoError(t, err)
+		}()
+
+		inputValue, err := p.MarkdownEditor("How to edit?", "", false)
+		require.NoError(t, err)
+		require.Equal(t, "", inputValue)
+	})
+}
+
+func TestSurveyPrompter(t *testing.T) {
+	// This not a comprehensive test of the survey prompter, but it does
+	// demonstrate that the survey prompter is used when the
+	// accessible prompter is disabled.
+	t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		p := newTestSurveyPrompter(t, console)
+
+		go func() {
+			// Wait for prompt to appear
+			_, err := console.ExpectString("Select a number")
+			require.NoError(t, err)
+
+			// Send a newline to select the first option
+			// Note: This would not work with the accessible prompter
+			// because it would requires sending a 1 to select the first option.
+			// So it proves we are seeing a survey prompter.
+			_, err = console.SendLine("")
+			require.NoError(t, err)
+		}()
+
+		selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, selectValue)
+	})
+}
+
+func newTestVirtualTerminal(t *testing.T) *expect.Console {
+	t.Helper()
+
+	// Create a PTY and hook up a virtual terminal emulator
+	ptm, pts, err := pty.Open()
+	require.NoError(t, err)
+
+	term := vt10x.New(vt10x.WithWriter(pts))
+
+	// Create a console via Expect that allows scripting against the terminal
+	consoleOpts := []expect.ConsoleOpt{
+		expect.WithStdin(ptm),
+		expect.WithStdout(term),
+		expect.WithCloser(ptm, pts),
+		failOnExpectError(t),
+		failOnSendError(t),
+		expect.WithDefaultTimeout(time.Second),
+	}
+
+	console, err := expect.NewConsole(consoleOpts...)
+	require.NoError(t, err)
+	t.Cleanup(func() { testCloser(t, console) })
+
+	return console
+}
+
+func newTestVirtualTerminalIOStreams(t *testing.T, console *expect.Console) *iostreams.IOStreams {
+	t.Helper()
+	io := &iostreams.IOStreams{
+		In:     console.Tty(),
+		Out:    console.Tty(),
+		ErrOut: console.Tty(),
+	}
+	io.SetStdinTTY(false)
+	io.SetStdoutTTY(false)
+	io.SetStderrTTY(false)
+	return io
+}
+
+// `echo` is chosen as the editor command because it immediately returns
+// a success exit code, returns an empty string, doesn't require any user input,
+// and since this file is only built on Linux, it is near guaranteed to be available.
+var editorCmd = "echo"
+
+func newTestAccessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter {
+	t.Helper()
+
+	io := newTestVirtualTerminalIOStreams(t, console)
+	io.SetAccessiblePrompterEnabled(true)
+
+	return prompter.New(editorCmd, io)
+}
+
+func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter {
+	t.Helper()
+
+	io := newTestVirtualTerminalIOStreams(t, console)
+	io.SetAccessiblePrompterEnabled(false)
+
+	return prompter.New(editorCmd, io)
+}
+
+// failOnExpectError adds an observer that will fail the test in a standardised way
+// if any expectation on the command output fails, without requiring an explicit
+// assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnExpectError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithExpectObserver(
+		func(matchers []expect.Matcher, buf string, err error) {
+			t.Helper()
+
+			if err == nil {
+				return
+			}
+
+			if len(matchers) == 0 {
+				t.Fatalf("Error occurred while matching %q: %s\n", buf, err)
+			}
+
+			var criteria []string
+			for _, matcher := range matchers {
+				criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
+			}
+			t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err)
+		},
+	)
+}
+
+// failOnSendError adds an observer that will fail the test in a standardised way
+// if any sending of input fails, without requiring an explicit assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnSendError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithSendObserver(
+		func(msg string, n int, err error) {
+			t.Helper()
+
+			if err != nil {
+				t.Fatalf("Failed to send %q: %s\n", msg, err)
+			}
+			if len(msg) != n {
+				t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg)
+			}
+		},
+	)
+}
+
+// testCloser is a helper to fail the test if a Closer fails to close.
+func testCloser(t *testing.T, closer io.Closer) {
+	t.Helper()
+	if err := closer.Close(); err != nil {
+		t.Errorf("Close failed: %s", err)
+	}
+}
diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go
index 845119a80..9bc89830a 100644
--- a/pkg/cmd/alias/delete/delete_test.go
+++ b/pkg/cmd/alias/delete/delete_test.go
@@ -162,6 +162,9 @@ func TestDeleteRun(t *testing.T) {
 			tt.opts.IO = ios
 
 			cfg := config.NewFromString(tt.config)
+			cfg.WriteFunc = func() error {
+				return nil
+			}
 			tt.opts.Config = func() (gh.Config, error) {
 				return cfg, nil
 			}
diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go
index ee58d55d0..321f7b7c0 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -367,72 +367,6 @@ func Test_NewCmdApi(t *testing.T) {
 			},
 			wantsErr: false,
 		},
-		{
-			name: "request path with container package name containing slashes",
-			cli:  "/user/packages/container/github.com/username/package_name --verbose",
-			wants: ApiOptions{
-				Hostname:            "",
-				RequestMethod:       "GET",
-				RequestMethodPassed: false,
-				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name",
-				RequestInputFile:    "",
-				RawFields:           []string(nil),
-				MagicFields:         []string(nil),
-				RequestHeaders:      []string(nil),
-				ShowResponseHeaders: false,
-				Paginate:            false,
-				Silent:              false,
-				CacheTTL:            0,
-				Template:            "",
-				FilterOutput:        "",
-				Verbose:             true,
-			},
-			wantsErr: false,
-		},
-		{
-			name: "request path with container package name containing slashes and restore",
-			cli:  "/user/packages/container/github.com/username/package_name/restore --verbose",
-			wants: ApiOptions{
-				Hostname:            "",
-				RequestMethod:       "GET",
-				RequestMethodPassed: false,
-				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name/restore",
-				RequestInputFile:    "",
-				RawFields:           []string(nil),
-				MagicFields:         []string(nil),
-				RequestHeaders:      []string(nil),
-				ShowResponseHeaders: false,
-				Paginate:            false,
-				Silent:              false,
-				CacheTTL:            0,
-				Template:            "",
-				FilterOutput:        "",
-				Verbose:             true,
-			},
-			wantsErr: false,
-		},
-		{
-			name: "request path with container package name containing slashes and versions",
-			cli:  "/user/packages/container/github.com/username/package_name/versions --verbose",
-			wants: ApiOptions{
-				Hostname:            "",
-				RequestMethod:       "GET",
-				RequestMethodPassed: false,
-				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name/versions",
-				RequestInputFile:    "",
-				RawFields:           []string(nil),
-				MagicFields:         []string(nil),
-				RequestHeaders:      []string(nil),
-				ShowResponseHeaders: false,
-				Paginate:            false,
-				Silent:              false,
-				CacheTTL:            0,
-				Template:            "",
-				FilterOutput:        "",
-				Verbose:             true,
-			},
-			wantsErr: false,
-		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/cmd/attestation/artifact/oci/client.go b/pkg/cmd/attestation/artifact/oci/client.go
index 87e0189a8..4e5acef3c 100644
--- a/pkg/cmd/attestation/artifact/oci/client.go
+++ b/pkg/cmd/attestation/artifact/oci/client.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"net/http"
 	"strings"
 
 	"github.com/google/go-containerregistry/pkg/authn"
@@ -69,33 +68,10 @@ func (c LiveClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, er
 	return &desc.Digest, name, nil
 }
 
-type noncompliantRegistryTransport struct{}
-
-// RoundTrip will check if a request and associated response fulfill the following:
-// 1. The response returns a 406 status code
-// 2. The request path contains /referrers/
-// If both conditions are met, the response's status code will be overwritten to 404
-// This is a temporary solution to handle non compliant registries that return
-// an unexpected status code 406 when the go-containerregistry library used
-// by this code attempts to make a request to the referrers API.
-// The go-containerregistry library can handle 404 response but not a 406 response.
-// See the related go-containerregistry issue: https://github.com/google/go-containerregistry/issues/1962
-func (a *noncompliantRegistryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-	resp, err := http.DefaultTransport.RoundTrip(req)
-	if err != nil {
-		return resp, err
-	}
-	if resp.StatusCode == http.StatusNotAcceptable && strings.Contains(req.URL.Path, "/referrers/") {
-		resp.StatusCode = http.StatusNotFound
-	}
-
-	return resp, nil
-}
-
 func (c LiveClient) GetAttestations(ref name.Reference, digest string) ([]*api.Attestation, error) {
 	attestations := make([]*api.Attestation, 0)
 
-	transportOpts := []remote.Option{remote.WithTransport(&noncompliantRegistryTransport{}), remote.WithAuthFromKeychain(authn.DefaultKeychain)}
+	transportOpts := []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)}
 	referrers, err := remote.Referrers(ref.Context().Digest(digest), transportOpts...)
 	if err != nil {
 		return attestations, fmt.Errorf("error getting referrers: %w", err)
diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go
index 6fbddd6da..b571eee01 100644
--- a/pkg/cmd/attestation/inspect/inspect.go
+++ b/pkg/cmd/attestation/inspect/inspect.go
@@ -105,7 +105,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 				config.TrustDomain = td
 			}
 
-			opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
+			sgVerifier, err := verification.NewLiveSigstoreVerifier(config)
+			if err != nil {
+				return fmt.Errorf("failed to create Sigstore verifier: %w", err)
+			}
+			opts.SigstoreVerifier = sgVerifier
 
 			if runF != nil {
 				return runF(opts)
diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go
index b33efaa28..ef3c35c20 100644
--- a/pkg/cmd/attestation/test/data/data.go
+++ b/pkg/cmd/attestation/test/data/data.go
@@ -5,13 +5,17 @@ import (
 	"testing"
 
 	"github.com/sigstore/sigstore-go/pkg/bundle"
-	sgData "github.com/sigstore/sigstore-go/pkg/testing/data"
 )
 
 //go:embed sigstore-js-2.1.0-bundle.json
 var SigstoreBundleRaw []byte
 
-// SigstoreBundle returns a test *sigstore.Bundle
+// SigstoreBundle returns a test sigstore-go bundle.Bundle
 func SigstoreBundle(t *testing.T) *bundle.Bundle {
-	return sgData.TestBundle(t, SigstoreBundleRaw)
+	b := &bundle.Bundle{}
+	err := b.UnmarshalJSON(SigstoreBundleRaw)
+	if err != nil {
+		t.Fatalf("failed to unmarshal sigstore bundle: %v", err)
+	}
+	return b
 }
diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go
index fdfd667bd..db419ebac 100644
--- a/pkg/cmd/attestation/verification/attestation.go
+++ b/pkg/cmd/attestation/verification/attestation.go
@@ -29,43 +29,37 @@ type FetchRemoteAttestationsParams struct {
 
 // GetLocalAttestations returns a slice of attestations read from a local bundle file.
 func GetLocalAttestations(path string) ([]*api.Attestation, error) {
+	var attestations []*api.Attestation
+	var err error
 	fileExt := filepath.Ext(path)
-	switch fileExt {
-	case ".json":
-		attestations, err := loadBundleFromJSONFile(path)
-		if err != nil {
-			var pathErr *os.PathError
-			if errors.As(err, &pathErr) {
-				return nil, fmt.Errorf("bundle could not be loaded from JSON file at %s", path)
-			} else if errors.Is(err, bundle.ErrValidation) {
-				return nil, err
-			}
-			return nil, fmt.Errorf("bundle content could not be parsed")
-		}
-		return attestations, nil
-	case ".jsonl":
-		attestations, err := loadBundlesFromJSONLinesFile(path)
-		if err != nil {
-			var pathErr *os.PathError
-			if errors.As(err, &pathErr) {
-				return nil, fmt.Errorf("bundles could not be loaded from JSON lines file at %s", path)
-			} else if errors.Is(err, bundle.ErrValidation) {
-				return nil, err
-			}
-			return nil, fmt.Errorf("bundle content could not be parsed")
+	if fileExt == ".json" {
+		attestations, err = loadBundleFromJSONFile(path)
+	} else if fileExt == ".jsonl" {
+		attestations, err = loadBundlesFromJSONLinesFile(path)
+	} else {
+		return nil, ErrUnrecognisedBundleExtension
+	}
+
+	if err != nil {
+		var pathErr *os.PathError
+		if errors.As(err, &pathErr) {
+			return nil, fmt.Errorf("could not load content from file path %s: %w", path, err)
+		} else if errors.Is(err, bundle.ErrValidation) {
+			return nil, err
 		}
-		return attestations, nil
+		return nil, fmt.Errorf("bundle content could not be parsed: %w", err)
 	}
-	return nil, ErrUnrecognisedBundleExtension
+
+	return attestations, nil
 }
 
 func loadBundleFromJSONFile(path string) ([]*api.Attestation, error) {
-	localAttestation, err := bundle.LoadJSONFromPath(path)
+	b, err := bundle.LoadJSONFromPath(path)
 	if err != nil {
 		return nil, err
 	}
 
-	return []*api.Attestation{{Bundle: localAttestation}}, nil
+	return []*api.Attestation{{Bundle: b}}, nil
 }
 
 func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) {
diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go
index 08f0ccfef..8acff0c37 100644
--- a/pkg/cmd/attestation/verification/attestation_test.go
+++ b/pkg/cmd/attestation/verification/attestation_test.go
@@ -92,7 +92,7 @@ func TestGetLocalAttestations(t *testing.T) {
 		path := "../test/data/not-found-bundle.json"
 		attestations, err := GetLocalAttestations(path)
 
-		require.ErrorContains(t, err, "bundle could not be loaded from JSON file")
+		require.ErrorContains(t, err, "could not load content from file path")
 		require.Nil(t, attestations)
 	})
 
@@ -100,7 +100,7 @@ func TestGetLocalAttestations(t *testing.T) {
 		path := "../test/data/not-found-bundle.jsonl"
 		attestations, err := GetLocalAttestations(path)
 
-		require.ErrorContains(t, err, "bundles could not be loaded from JSON lines file")
+		require.ErrorContains(t, err, "could not load content from file path")
 		require.Nil(t, attestations)
 	})
 
diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go
index 6dd31dac0..190ea5c0f 100644
--- a/pkg/cmd/attestation/verification/sigstore.go
+++ b/pkg/cmd/attestation/verification/sigstore.go
@@ -44,12 +44,11 @@ type SigstoreVerifier interface {
 }
 
 type LiveSigstoreVerifier struct {
-	TrustedRoot  string
 	Logger       *io.Handler
 	NoPublicGood bool
-	// If tenancy mode is not used, trust domain is empty
-	TrustDomain    string
-	TUFMetadataDir o.Option[string]
+	PublicGood   *verify.SignedEntityVerifier
+	GitHub       *verify.SignedEntityVerifier
+	Custom       map[string]*verify.SignedEntityVerifier
 }
 
 var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@@ -57,56 +56,43 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified")
 // NewLiveSigstoreVerifier creates a new LiveSigstoreVerifier struct
 // that is used to verify artifacts and attestations against the
 // Public Good, GitHub, or a custom trusted root.
-func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier {
-	return &LiveSigstoreVerifier{
-		TrustedRoot:    config.TrustedRoot,
-		Logger:         config.Logger,
-		NoPublicGood:   config.NoPublicGood,
-		TrustDomain:    config.TrustDomain,
-		TUFMetadataDir: config.TUFMetadataDir,
+func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) {
+	liveVerifier := &LiveSigstoreVerifier{
+		Logger:       config.Logger,
+		NoPublicGood: config.NoPublicGood,
 	}
-}
-
-func getBundleIssuer(b *bundle.Bundle) (string, error) {
-	if !b.MinVersion("0.2") {
-		return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
-	}
-	verifyContent, err := b.VerificationContent()
-	if err != nil {
-		return "", fmt.Errorf("failed to get bundle verification content: %v", err)
+	// if a custom trusted root is set, configure custom verifiers
+	if config.TrustedRoot != "" {
+		customVerifiers, err := createCustomVerifiers(config.TrustedRoot, config.NoPublicGood)
+		if err != nil {
+			return nil, err
+		}
+		liveVerifier.Custom = customVerifiers
+		return liveVerifier, nil
 	}
-	leafCert := verifyContent.Certificate()
-	if leafCert == nil {
-		return "", fmt.Errorf("leaf cert not found")
+	if !config.NoPublicGood {
+		publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir)
+		if err != nil {
+			return nil, err
+		}
+		liveVerifier.PublicGood = publicGoodVerifier
 	}
-	if len(leafCert.Issuer.Organization) != 1 {
-		return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
+	github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir)
+	if err != nil {
+		return nil, err
 	}
-	return leafCert.Issuer.Organization[0], nil
-}
+	liveVerifier.GitHub = github
 
-func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
-	// if no custom trusted root is set, attempt to create a Public Good or
-	// GitHub Sigstore verifier
-	if v.TrustedRoot == "" {
-		switch issuer {
-		case PublicGoodIssuerOrg:
-			if v.NoPublicGood {
-				return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
-			}
-			return newPublicGoodVerifier(v.TUFMetadataDir)
-		case GitHubIssuerOrg:
-			return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir)
-		default:
-			return nil, fmt.Errorf("leaf certificate issuer is not recognized")
-		}
-	}
+	return liveVerifier, nil
+}
 
-	customTrustRoots, err := os.ReadFile(v.TrustedRoot)
+func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) {
+	customTrustRoots, err := os.ReadFile(trustedRoot)
 	if err != nil {
-		return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err)
+		return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err)
 	}
 
+	verifiers := make(map[string]*verify.SignedEntityVerifier)
 	reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
 	var line []byte
 	var readError error
@@ -130,10 +116,11 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
 				return nil, err
 			}
 
-			// if the custom trusted root issuer is not set or doesn't match the given issuer, skip it
-			if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer {
+			// if the custom trusted root issuer is not set, skip it
+			if len(lowestCert.Issuer.Organization) == 0 {
 				continue
 			}
+			issuer := lowestCert.Issuer.Organization[0]
 
 			// Determine what policy to use with this trusted root.
 			//
@@ -141,21 +128,88 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
 			// issuer. We *must* use the trusted root provided.
 			switch issuer {
 			case PublicGoodIssuerOrg:
-				if v.NoPublicGood {
+				if noPublicGood {
 					return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
 				}
-				return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
+				if _, ok := verifiers[PublicGoodIssuerOrg]; ok {
+					// we have already created a public good verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
+				publicGood, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[PublicGoodIssuerOrg] = publicGood
 			case GitHubIssuerOrg:
-				return newGitHubVerifierWithTrustedRoot(trustedRoot)
+				if _, ok := verifiers[GitHubIssuerOrg]; ok {
+					// we have already created a github verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
+				github, err := newGitHubVerifierWithTrustedRoot(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[GitHubIssuerOrg] = github
 			default:
+				if _, ok := verifiers[issuer]; ok {
+					// we have already created a custom verifier with this custom trusted root
+					// so we skip it
+					continue
+				}
 				// Make best guess at reasonable policy
-				return newCustomVerifier(trustedRoot)
+				custom, err := newCustomVerifier(trustedRoot)
+				if err != nil {
+					return nil, err
+				}
+				verifiers[issuer] = custom
 			}
 		}
 		line, readError = reader.ReadBytes('\n')
 	}
+	return verifiers, nil
+}
+
+func getBundleIssuer(b *bundle.Bundle) (string, error) {
+	if !b.MinVersion("0.2") {
+		return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
+	}
+	verifyContent, err := b.VerificationContent()
+	if err != nil {
+		return "", fmt.Errorf("failed to get bundle verification content: %v", err)
+	}
+	leafCert := verifyContent.Certificate()
+	if leafCert == nil {
+		return "", fmt.Errorf("leaf cert not found")
+	}
+	if len(leafCert.Issuer.Organization) != 1 {
+		return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
+	}
+	return leafCert.Issuer.Organization[0], nil
+}
 
-	return nil, fmt.Errorf("unable to use provided trusted roots")
+func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
+	// if no custom trusted root is set, return either the Public Good or GitHub verifier
+	// If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls
+	if v.Custom != nil {
+		custom, ok := v.Custom[issuer]
+		if !ok {
+			return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer)
+		}
+		return custom, nil
+	}
+	switch issuer {
+	case PublicGoodIssuerOrg:
+		if v.NoPublicGood {
+			return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
+		}
+		return v.PublicGood, nil
+	case GitHubIssuerOrg:
+		return v.GitHub, nil
+	default:
+		return nil, fmt.Errorf("leaf certificate issuer is not recognized")
+	}
 }
 
 func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) {
@@ -177,7 +231,7 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif
 	// determine which verifier should attempt verification against the bundle
 	verifier, err := v.chooseVerifier(issuer)
 	if err != nil {
-		return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err)
+		return nil, fmt.Errorf("failed to choose verifier based on provided bundle issuer: %v", err)
 	}
 
 	v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer)
diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go
index 987fb9caa..2a2d3beea 100644
--- a/pkg/cmd/attestation/verification/sigstore_integration_test.go
+++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go
@@ -50,10 +50,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 
 	for _, tc := range testcases {
 		t.Run(tc.name, func(t *testing.T) {
-			verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+			verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 				Logger:         io.NewTestHandler(),
 				TUFMetadataDir: o.Some(t.TempDir()),
 			})
+			require.NoError(t, err)
 
 			results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
 
@@ -69,10 +70,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	}
 
 	t.Run("with 2/3 verified attestations", func(t *testing.T) {
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
 		attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
@@ -86,10 +88,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	})
 
 	t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
 		attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json")
@@ -110,10 +113,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 
 		attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
 
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		results, err := verifier.Verify(attestations, githubPolicy)
 		require.Len(t, results, 1)
@@ -123,11 +127,12 @@ func TestLiveSigstoreVerifier(t *testing.T) {
 	t.Run("with custom trusted root", func(t *testing.T) {
 		attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
 
-		verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
 			Logger:         io.NewTestHandler(),
 			TrustedRoot:    test.NormalizeRelativePath("../test/data/trusted_root.json"),
 			TUFMetadataDir: o.Some(t.TempDir()),
 		})
+		require.NoError(t, err)
 
 		results, err := verifier.Verify(attestations, publicGoodPolicy(t))
 		require.Len(t, results, 2)
diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go
index 9ff174141..73452c425 100644
--- a/pkg/cmd/attestation/verify/attestation_integration_test.go
+++ b/pkg/cmd/attestation/verify/attestation_integration_test.go
@@ -25,10 +25,11 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
 }
 
 func TestVerifyAttestations(t *testing.T) {
-	sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
+	sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
 		Logger:         io.NewTestHandler(),
 		TUFMetadataDir: o.Some(t.TempDir()),
 	})
+	require.NoError(t, err)
 
 	certSummary := certificate.Summary{}
 	certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore"
diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go
index 1060a781e..1d1595eca 100644
--- a/pkg/cmd/attestation/verify/policy.go
+++ b/pkg/cmd/attestation/verify/policy.go
@@ -161,7 +161,7 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) {
 	// if the provided workflow did not match the expect format
 	// we move onto creating a signer workflow using the provided host name
 	if hostname == "" {
-		return "", errors.New("unknown host")
+		return "", errors.New("unknown signer workflow host")
 	}
 
 	return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil
diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go
index d376498b6..ff10cad11 100644
--- a/pkg/cmd/attestation/verify/policy_test.go
+++ b/pkg/cmd/attestation/verify/policy_test.go
@@ -275,7 +275,7 @@ func TestValidateSignerWorkflow(t *testing.T) {
 			name:                   "workflow with no host specified",
 			providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml",
 			expectErr:              true,
-			errContains:            "unknown host",
+			errContains:            "unknown signer workflow host",
 		},
 		{
 			name:                   "workflow with default host",
diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go
index 65ae8ca3e..b3bad519a 100644
--- a/pkg/cmd/attestation/verify/verify.go
+++ b/pkg/cmd/attestation/verify/verify.go
@@ -30,58 +30,107 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 			Verify the integrity and provenance of an artifact using its associated
 			cryptographically signed attestations.
 
-			In order to verify an attestation, you must validate the identity of the Actions
-			workflow that produced the attestation (a.k.a. the signer workflow). Given this
-			identity, the verification process checks the signatures in the attestations,
-			and confirms that the attestation refers to provided artifact.
+			## Understanding Verification
 
-			To specify the artifact, the command requires:
+			An attestation is a claim (i.e. a provenance statement) made by an actor
+			(i.e. a GitHub Actions workflow) regarding a subject (i.e. an artifact).
+
+			In order to verify an attestation, you must provide an artifact and validate:
+			* the identity of the actor that produced the attestation
+			* the expected attestation predicate type (the nature of the claim)
+
+			By default, this command enforces the %[1]s%[2]s%[1]s
+			predicate type. To verify other attestation predicate types use the
+			%[1]s--predicate-type%[1]s flag.
+
+			The "actor identity" consists of:
+			* the repository or the repository owner the artifact is linked with
+			* the Actions workflow that produced the attestation (a.k.a the
+			  signer workflow)
+
+			This identity is then validated against the attestation's certificate's
+			SourceRepository, SourceRepositoryOwner, and SubjectAlternativeName
+			(SAN) fields, among others.
+
+			It is up to you to decide how precisely you want to enforce this identity.
+
+			At a minimum, this command requires either:
+			* the %[1]s--owner%[1]s flag (e.g. --owner github), or
+			* the %[1]s--repo%[1]s flag (e.g. --repo github/example)
+
+			The more precisely you specify the identity, the more control you will
+			have over the security guarantees offered by the verification process.
+
+			Ideally, the path of the signer workflow is also validated using the
+			%[1]s--signer-workflow%[1]s or %[1]s--cert-identity%[1]s flags.
+
+			Please note: if your attestation was generated via a reusable workflow then
+			that reusable workflow is the signer whose identity needs to be validated.
+			In this situation, you must use either the %[1]s--signer-workflow%[1]s or
+			the %[1]s--signer-repo%[1]s flag.
+
+			For more options, see the other available flags.
+
+			## Loading Artifacts And Attestations
+
+			To specify the artifact, this command requires:
 			* a file path to an artifact, or
 			* a container image URI (e.g. %[1]soci://<image-uri>%[1]s)
 			  * (note that if you provide an OCI URL, you must already be authenticated with
 			its container registry)
 
-			To fetch the attestation, and validate the identity of the signer, the command
-			requires either:
-			* the %[1]s--repo%[1]s flag (e.g. --repo github/example).
-			* the %[1]s--owner%[1]s flag (e.g. --owner github), or
+			By default, this command will attempt to fetch relevant attestations via the
+			GitHub API using the values provided to %[1]s--owner%[1]s or  %[1]s--repo%[1]s.
+
+			To instead fetch attestations from your artifact's OCI registry, use the
+			%[1]s--bundle-from-oci%[1]s flag.
 
-			The %[1]s--repo%[1]s flag value must match the name of the GitHub repository
-			that the artifact is linked with.
+			For offline verification using attestations stored on disk (c.f. the download command)
+			provide a path to the %[1]s--bundle%[1]s flag.
 
-			The %[1]s--owner%[1]s flag value must match the name of the GitHub organization
-			that the artifact's linked repository belongs to.
+			## Additional Policy Enforcement
 
-			By default, the verify command will:
-			- only verify provenance attestations
-			- attempt to fetch relevant attestations via the GitHub API.
+			Given the %[1]s--format=json%[1]s flag, upon successful verification this
+			command will output a JSON array containing one entry per verified attestation.
 
-			To verify other types of attestations, use the %[1]s--predicate-type%[1]s flag.
+			This output can then be used for additional policy enforcement, i.e. by being
+			piped into a policy engine.
 
-			To use your artifact's OCI registry instead of GitHub's API, use the
-			%[1]s--bundle-from-oci%[1]s flag. For offline verification, using attestations
-			stored on desk (c.f. the download command), provide a path to the %[1]s--bundle%[1]s flag.
+			Each object in the array contains two properties:
+			* an %[1]sattestation%[1]s object, which contains the bundle that was verified
+			* a %[1]sverificationResult%[1]s object, which is a parsed representation of the
+			  contents of the bundle that was verified.
 
-			To see the full results that are generated upon successful verification, i.e.
-			for use with a policy engine, provide the %[1]s--format=json%[1]s flag.
+			Within the %[1]sverificationResult%[1]s object you will find:
+			* %[1]ssignature.certificate%[1]s, which is a parsed representation of the X.509
+			  certificate embedded in the attestation,
+			* %[1]sverifiedTimestamps%[1]s, an array of objects denoting when the attestation
+			  was witnessed by a transparency log or a timestamp authority
+			* %[1]sstatement%[1]s, which contains the %[1]ssubject%[1]s array referencing artifacts,
+			  the %[1]spredicateType%[1]s field, and the %[1]spredicate%[1]s object which contains
+			  additional, often user-controllable, metadata
 
-			The signer workflow's identity is validated against the Subject Alternative Name (SAN)
-			within the attestation certificate. Often, the signer workflow is the
-			same workflow that started the run and generated the attestation, and will be
-			located inside your repository. For this reason, by default this command uses
-			either the %[1]s--repo%[1]s or the %[1]s--owner%[1]s flag value to validate the SAN.
+			IMPORTANT: please note that only the %[1]ssignature.certificate%[1]s and the
+			%[1]sverifiedTimestamps%[1]s properties contain values that cannot be
+			manipulated by the workflow that originated the attestation.
 
-			However, sometimes the caller workflow is not the same workflow that
-			performed the signing. If your attestation was generated via a reusable
-			workflow, then that reusable workflow is the signer whose identity needs to be
-			validated. In this situation, the signer workflow may or may not be located
-			inside your %[1]s--repo%[1]s or %[1]s--owner%[1]s.
+			When dealing with attestations created within GitHub Actions, the contents of
+			%[1]ssignature.certificate%[1]s are populated directly from the OpenID Connect
+			token that GitHub has generated. The contents of the %[1]sverifiedTimestamps%[1]s
+			array are populated from the signed timestamps originating from either a
+			transparency log or a timestamp authority – and likewise cannot be forged by users.
 
-			When using reusable workflows, use the %[1]s--signer-repo%[1]s, %[1]s--signer-workflow%[1]s,
-			or %[1]s--cert-identity%[1]s flags to validate the signer workflow's identity.
+			When designing policy enforcement using this output, special care must be taken
+			when examining the contents of the %[1]sstatement.predicate%[1]s property:
+			should an attacker gain access to your workflow's execution context, they
+			could then falsify the contents of the %[1]sstatement.predicate%[1]s.
 
-			For more policy verification options, see the other available flags.
-			`, "`"),
+			To mitigate this attack vector, consider using a "trusted builder": when generating
+			an artifact, have the build and attestation signing occur within a reusable workflow
+			whose execution cannot be influenced by input provided through the caller workflow.
+
+			See above re: %[1]s--signer-workflow%[1]s.
+			`, "`", verification.SLSAPredicateV1),
 		Example: heredoc.Doc(`
 			# Verify an artifact linked with a repository
 			$ gh attestation verify example.bin --repo github/example
@@ -162,7 +211,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 				return runF(opts)
 			}
 
-			opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
+			sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config)
+			if err != nil {
+				return fmt.Errorf("error creating Sigstore verifier: %w", err)
+			}
+			opts.SigstoreVerifier = sigstoreVerifier
 			opts.Config = f.Config
 
 			if err := runVerify(opts); err != nil {
@@ -181,23 +234,23 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 	verifyCmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository name in the format <owner>/<repo>")
 	verifyCmd.MarkFlagsMutuallyExclusive("owner", "repo")
 	verifyCmd.MarkFlagsOneRequired("owner", "repo")
-	verifyCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", verification.SLSAPredicateV1, "Filter attestations by provided predicate type")
 	verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Do not verify attestations signed with Sigstore public good instance")
 	verifyCmd.Flags().StringVarP(&opts.TrustedRoot, "custom-trusted-root", "", "", "Path to a trusted_root.jsonl file; likely for offline verification")
 	verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch")
 	cmdutil.AddFormatFlags(verifyCmd, &opts.exporter)
+	verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use")
 	// policy enforcement flags
+	verifyCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", verification.SLSAPredicateV1, "Enforce that verified attestations' predicate type matches the provided value")
 	verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners")
-	verifyCmd.Flags().StringVarP(&opts.SAN, "cert-identity", "", "", "Enforce that the certificate's subject alternative name matches the provided value exactly")
-	verifyCmd.Flags().StringVarP(&opts.SANRegex, "cert-identity-regex", "i", "", "Enforce that the certificate's subject alternative name matches the provided regex")
-	verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format <owner>/<repo>")
-	verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]<owner>/<repo>/<path>/<to>/<workflow>")
+	verifyCmd.Flags().StringVarP(&opts.SAN, "cert-identity", "", "", "Enforce that the certificate's SubjectAlternativeName matches the provided value exactly")
+	verifyCmd.Flags().StringVarP(&opts.SANRegex, "cert-identity-regex", "i", "", "Enforce that the certificate's SubjectAlternativeName matches the provided regex")
+	verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Enforce that the workflow that signed the attestation's repository matches the provided value (<owner>/<repo>)")
+	verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Enforce that the workflow that signed the attestation matches the provided value ([host/]<owner>/<repo>/<path>/<to>/<workflow>)")
 	verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex", "signer-repo", "signer-workflow")
-	verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verification.GitHubOIDCIssuer, "Issuer of the OIDC token")
-	verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use")
-	verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Digest associated with the signer workflow")
-	verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Ref associated with the source workflow")
-	verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Digest associated with the source workflow")
+	verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verification.GitHubOIDCIssuer, "Enforce that the issuer of the OIDC token matches the provided value")
+	verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Enforce that the digest associated with the signer workflow matches the provided value")
+	verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Enforce that the git ref associated with the source repository matches the provided value")
+	verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Enforce that the digest associated with the source repository matches the provided value")
 
 	return verifyCmd
 }
diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go
index 09479995c..92864f78e 100644
--- a/pkg/cmd/attestation/verify/verify_integration_test.go
+++ b/pkg/cmd/attestation/verify/verify_integration_test.go
@@ -33,6 +33,8 @@ func TestVerifyIntegration(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	publicGoodOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -44,7 +46,7 @@ func TestVerifyIntegration(t *testing.T) {
 		Owner:            "sigstore",
 		PredicateType:    verification.SLSAPredicateV1,
 		SANRegex:         "^https://github.com/sigstore/",
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with valid owner", func(t *testing.T) {
@@ -106,6 +108,8 @@ func TestVerifyIntegration(t *testing.T) {
 	})
 
 	t.Run("with bundle from OCI registry", func(t *testing.T) {
+		sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+		require.NoError(t, err)
 		opts := Options{
 			APIClient:             api.NewLiveClient(hc, host, logger),
 			ArtifactPath:          "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
@@ -117,10 +121,10 @@ func TestVerifyIntegration(t *testing.T) {
 			Owner:                 "github",
 			PredicateType:         verification.SLSAPredicateV1,
 			SANRegex:              "^https://github.com/github/",
-			SigstoreVerifier:      verification.NewLiveSigstoreVerifier(sigstoreConfig),
+			SigstoreVerifier:      sigstoreVerifier,
 		}
 
-		err := runVerify(&opts)
+		err = runVerify(&opts)
 		require.NoError(t, err)
 	})
 }
@@ -145,6 +149,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -154,7 +160,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
 		OCIClient:        oci.NewLiveClient(),
 		OIDCIssuer:       "https://token.actions.githubusercontent.com/hammer-time",
 		PredicateType:    verification.SLSAPredicateV1,
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with owner and valid workflow SAN", func(t *testing.T) {
@@ -216,6 +222,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -225,7 +233,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
 		OCIClient:        oci.NewLiveClient(),
 		OIDCIssuer:       verification.GitHubOIDCIssuer,
 		PredicateType:    verification.SLSAPredicateV1,
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	t.Run("with owner and valid reusable workflow SAN", func(t *testing.T) {
@@ -306,6 +314,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
 
 	host, _ := auth.DefaultHost()
 
+	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
+	require.NoError(t, err)
 	baseOpts := Options{
 		APIClient:        api.NewLiveClient(hc, host, logger),
 		ArtifactPath:     artifactPath,
@@ -318,7 +328,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
 		Owner:            "malancas",
 		PredicateType:    verification.SLSAPredicateV1,
 		Repo:             "malancas/attest-demo",
-		SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
+		SigstoreVerifier: sigstoreVerifier,
 	}
 
 	type testcase struct {
diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go
index 65f83d659..27260e857 100644
--- a/pkg/cmd/config/list/list_test.go
+++ b/pkg/cmd/config/list/list_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -91,14 +92,19 @@ func Test_listRun(t *testing.T) {
 				return cfg
 			}(),
 			input: &ListOptions{Hostname: "HOST"},
-			stdout: `git_protocol=ssh
-editor=/usr/bin/vim
-prompt=disabled
-prefer_editor_prompt=enabled
-pager=less
-http_unix_socket=
-browser=brave
-`,
+			stdout: heredoc.Doc(`
+				git_protocol=ssh
+				editor=/usr/bin/vim
+				prompt=disabled
+				prefer_editor_prompt=enabled
+				pager=less
+				http_unix_socket=
+				browser=brave
+				color_labels=disabled
+				accessible_colors=disabled
+				accessible_prompter=disabled
+				spinner=enabled
+			`),
 		},
 	}
 
diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go
index 6f0afc3a6..6928c6ed9 100644
--- a/pkg/cmd/extension/extension_test.go
+++ b/pkg/cmd/extension/extension_test.go
@@ -102,3 +102,84 @@ func TestOwnerCached(t *testing.T) {
 
 	assert.Equal(t, "cli", e.Owner())
 }
+
+func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name: "gh-bin-ext",
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}
+
+func TestIsPinnedBinaryExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name:     "gh-bin-ext",
+		IsPinned: true,
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+}
+
+func TestIsPinnedGitExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubExtension(extPath))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedGitExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubPinnedExtension(extPath, "abcd1234"))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedLocalExtension(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubLocalExtension(tempDir, extPath))
+	e := &Extension{
+		kind: LocalKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}
diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go
index 94955bb30..d7bfe39fd 100644
--- a/pkg/cmd/factory/default_test.go
+++ b/pkg/cmd/factory/default_test.go
@@ -432,6 +432,230 @@ func Test_ioStreams_prompt(t *testing.T) {
 	}
 }
 
+func Test_ioStreams_spinnerDisabled(t *testing.T) {
+	tests := []struct {
+		name            string
+		config          gh.Config
+		spinnerDisabled bool
+		env             map[string]string
+	}{
+		{
+			name:            "default config",
+			spinnerDisabled: false,
+		},
+		{
+			name:            "config with spinner disabled",
+			config:          disableSpinnersConfig(),
+			spinnerDisabled: true,
+		},
+		{
+			name:            "config with spinner enabled",
+			config:          enableSpinnersConfig(),
+			spinnerDisabled: false,
+		},
+		{
+			name:            "spinner disabled via GH_SPINNER_DISABLED env var = 0",
+			env:             map[string]string{"GH_SPINNER_DISABLED": "0"},
+			spinnerDisabled: false,
+		},
+		{
+			name:            "spinner disabled via GH_SPINNER_DISABLED env var = false",
+			env:             map[string]string{"GH_SPINNER_DISABLED": "false"},
+			spinnerDisabled: false,
+		},
+		{
+			name:            "spinner disabled via GH_SPINNER_DISABLED env var = no",
+			env:             map[string]string{"GH_SPINNER_DISABLED": "no"},
+			spinnerDisabled: false,
+		},
+		{
+			name:            "spinner enabled via GH_SPINNER_DISABLED env var = 1",
+			env:             map[string]string{"GH_SPINNER_DISABLED": "1"},
+			spinnerDisabled: true,
+		},
+		{
+			name:            "spinner enabled via GH_SPINNER_DISABLED env var = true",
+			env:             map[string]string{"GH_SPINNER_DISABLED": "true"},
+			spinnerDisabled: true,
+		},
+		{
+			name:            "config enabled but env disabled, respects env",
+			config:          enableSpinnersConfig(),
+			env:             map[string]string{"GH_SPINNER_DISABLED": "true"},
+			spinnerDisabled: true,
+		},
+		{
+			name:            "config disabled but env enabled, respects env",
+			config:          disableSpinnersConfig(),
+			env:             map[string]string{"GH_SPINNER_DISABLED": "false"},
+			spinnerDisabled: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			f := New("1")
+			f.Config = func() (gh.Config, error) {
+				if tt.config == nil {
+					return config.NewBlankConfig(), nil
+				} else {
+					return tt.config, nil
+				}
+			}
+			io := ioStreams(f)
+			assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled())
+		})
+	}
+}
+
+func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) {
+	tests := []struct {
+		name                      string
+		config                    gh.Config
+		accessiblePrompterEnabled bool
+		env                       map[string]string
+	}{
+		{
+			name:                      "default config",
+			accessiblePrompterEnabled: false,
+		},
+		{
+			name:                      "config with accessible prompter enabled",
+			config:                    enableAccessiblePrompterConfig(),
+			accessiblePrompterEnabled: true,
+		},
+		{
+			name:                      "config with accessible prompter disabled",
+			config:                    disableAccessiblePrompterConfig(),
+			accessiblePrompterEnabled: false,
+		},
+		{
+			name:                      "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1",
+			env:                       map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"},
+			accessiblePrompterEnabled: true,
+		},
+		{
+			name:                      "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true",
+			env:                       map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"},
+			accessiblePrompterEnabled: true,
+		},
+		{
+			name:                      "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0",
+			env:                       map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"},
+			accessiblePrompterEnabled: false,
+		},
+		{
+			name:                      "config disabled but env enabled, respects env",
+			config:                    disableAccessiblePrompterConfig(),
+			env:                       map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"},
+			accessiblePrompterEnabled: true,
+		},
+		{
+			name:                      "config enabled but env disabled, respects env",
+			config:                    enableAccessiblePrompterConfig(),
+			env:                       map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"},
+			accessiblePrompterEnabled: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			f := New("1")
+			f.Config = func() (gh.Config, error) {
+				if tt.config == nil {
+					return config.NewBlankConfig(), nil
+				} else {
+					return tt.config, nil
+				}
+			}
+			io := ioStreams(f)
+			assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled())
+		})
+	}
+}
+
+func Test_ioStreams_colorLabels(t *testing.T) {
+	tests := []struct {
+		name               string
+		config             gh.Config
+		colorLabelsEnabled bool
+		env                map[string]string
+	}{
+		{
+			name:               "default config",
+			colorLabelsEnabled: false,
+		},
+		{
+			name:               "config with colorLabels enabled",
+			config:             enableColorLabelsConfig(),
+			colorLabelsEnabled: true,
+		},
+		{
+			name:               "config with colorLabels disabled",
+			config:             disableColorLabelsConfig(),
+			colorLabelsEnabled: false,
+		},
+		{
+			name:               "colorLabels enabled via `1` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "1"},
+			colorLabelsEnabled: true,
+		},
+		{
+			name:               "colorLabels enabled via `true` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "true"},
+			colorLabelsEnabled: true,
+		},
+		{
+			name:               "colorLabels enabled via `yes` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "yes"},
+			colorLabelsEnabled: true,
+		},
+		{
+			name:               "colorLabels disable via empty string in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": ""},
+			colorLabelsEnabled: false,
+		},
+		{
+			name:               "colorLabels disabled via `0` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "0"},
+			colorLabelsEnabled: false,
+		},
+		{
+			name:               "colorLabels disabled via `false` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "false"},
+			colorLabelsEnabled: false,
+		},
+		{
+			name:               "colorLabels disabled via `no` in GH_COLOR_LABELS env var",
+			env:                map[string]string{"GH_COLOR_LABELS": "no"},
+			colorLabelsEnabled: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.env != nil {
+				for k, v := range tt.env {
+					t.Setenv(k, v)
+				}
+			}
+			f := New("1")
+			f.Config = func() (gh.Config, error) {
+				if tt.config == nil {
+					return config.NewBlankConfig(), nil
+				} else {
+					return tt.config, nil
+				}
+			}
+			io := ioStreams(f)
+			assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels())
+		})
+	}
+}
+
 func TestSSOURL(t *testing.T) {
 	tests := []struct {
 		name       string
@@ -537,3 +761,27 @@ func pagerConfig() gh.Config {
 func disablePromptConfig() gh.Config {
 	return config.NewFromString("prompt: disabled")
 }
+
+func enableAccessiblePrompterConfig() gh.Config {
+	return config.NewFromString("accessible_prompter: enabled")
+}
+
+func disableAccessiblePrompterConfig() gh.Config {
+	return config.NewFromString("accessible_prompter: disabled")
+}
+
+func disableSpinnersConfig() gh.Config {
+	return config.NewFromString("spinner: disabled")
+}
+
+func enableSpinnersConfig() gh.Config {
+	return config.NewFromString("spinner: enabled")
+}
+
+func disableColorLabelsConfig() gh.Config {
+	return config.NewFromString("color_labels: disabled")
+}
+
+func enableColorLabelsConfig() gh.Config {
+	return config.NewFromString("color_labels: enabled")
+}
diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go
index 237486eb3..44f0ba284 100644
--- a/pkg/cmd/gist/create/create_test.go
+++ b/pkg/cmd/gist/create/create_test.go
@@ -225,7 +225,7 @@ func Test_createRun(t *testing.T) {
 			responseStatus: http.StatusOK,
 		},
 		{
-			name: "multiple files",
+			name: "when both a file and the stdin '-' are provided, it matches on all the files passed in and stdin",
 			opts: &CreateOptions{
 				Filenames: []string{fixtureFile, "-"},
 			},
@@ -248,6 +248,30 @@ func Test_createRun(t *testing.T) {
 			},
 			responseStatus: http.StatusOK,
 		},
+		{
+			name: "when both a file and the stdin '-' are provided, but '-' is not the last argument, it matches on all the files provided and stdin",
+			opts: &CreateOptions{
+				Filenames: []string{"-", fixtureFile},
+			},
+			stdin:      "cool stdin content",
+			wantOut:    "https://gist.github.com/aa5a315d61ae9438b18d\n",
+			wantStderr: "- Creating gist with multiple files\n✓ Created secret gist fixture.txt\n",
+			wantErr:    false,
+			wantParams: map[string]interface{}{
+				"description": "",
+				"updated_at":  "0001-01-01T00:00:00Z",
+				"public":      false,
+				"files": map[string]interface{}{
+					"fixture.txt": map[string]interface{}{
+						"content": "{}",
+					},
+					"gistfile1.txt": map[string]interface{}{
+						"content": "cool stdin content",
+					},
+				},
+			},
+			responseStatus: http.StatusOK,
+		},
 		{
 			name: "file with empty content",
 			opts: &CreateOptions{
diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go
index 0acfae109..14351418f 100644
--- a/pkg/cmd/gist/list/list_test.go
+++ b/pkg/cmd/gist/list/list_test.go
@@ -486,9 +486,9 @@ func Test_listRun(t *testing.T) {
 				)
 			},
 			wantOut: heredoc.Docf(`
-				%[1]s[0;2;4;37mID  %[1]s[0m  %[1]s[0;2;4;37mDESCRIPTION                  %[1]s[0m  %[1]s[0;2;4;37mFILES  %[1]s[0m  %[1]s[0;2;4;37mVISIBILITY%[1]s[0m  %[1]s[0;2;4;37mUPDATED          %[1]s[0m
-				1234  %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m  1 file   %[1]s[0;32mpublic    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[m
-				2345  %[1]s[0;1;39mmatch in the file name       %[1]s[0m  %[1]s[0;30;43m2 files%[1]s[0m  %[1]s[0;31msecret    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[m
+				%[1]s[0;4;39mID  %[1]s[0m  %[1]s[0;4;39mDESCRIPTION                  %[1]s[0m  %[1]s[0;4;39mFILES  %[1]s[0m  %[1]s[0;4;39mVISIBILITY%[1]s[0m  %[1]s[0;4;39mUPDATED          %[1]s[0m
+				1234  %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m  1 file   %[1]s[0;32mpublic    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[0m
+				2345  %[1]s[0;1;39mmatch in the file name       %[1]s[0m  %[1]s[0;30;43m2 files%[1]s[0m  %[1]s[0;31msecret    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[0m
 			`, "\x1b"),
 		},
 		{
@@ -654,50 +654,57 @@ func Test_highlightMatch(t *testing.T) {
 	tests := []struct {
 		name  string
 		input string
-		color bool
+		cs    *iostreams.ColorScheme
 		want  string
 	}{
 		{
 			name:  "single match",
 			input: "Octo",
+			cs:    &iostreams.ColorScheme{},
 			want:  "Octo",
 		},
 		{
 			name:  "single match (color)",
 			input: "Octo",
-			color: true,
-			want:  "\x1b[0;30;43mOcto\x1b[0m",
+			cs: &iostreams.ColorScheme{
+				Enabled: true,
+			},
+			want: "\x1b[0;30;43mOcto\x1b[0m",
 		},
 		{
 			name:  "single match with extra",
 			input: "Hello, Octocat!",
+			cs:    &iostreams.ColorScheme{},
 			want:  "Hello, Octocat!",
 		},
 		{
 			name:  "single match with extra (color)",
 			input: "Hello, Octocat!",
-			color: true,
-			want:  "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
+			cs: &iostreams.ColorScheme{
+				Enabled: true,
+			},
+			want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
 		},
 		{
 			name:  "multiple matches",
 			input: "Octocat/octo",
+			cs:    &iostreams.ColorScheme{},
 			want:  "Octocat/octo",
 		},
 		{
 			name:  "multiple matches (color)",
 			input: "Octocat/octo",
-			color: true,
-			want:  "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
+			cs: &iostreams.ColorScheme{
+				Enabled: true,
+			},
+			want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			cs := iostreams.NewColorScheme(tt.color, false, false)
-
 			matched := false
-			got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
+			got, err := highlightMatch(tt.input, regex, &matched, tt.cs.Blue, tt.cs.Highlight)
 			assert.NoError(t, err)
 			assert.True(t, matched)
 			assert.Equal(t, tt.want, got)
diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go
index 704766e10..706b85f10 100644
--- a/pkg/cmd/gist/view/view_test.go
+++ b/pkg/cmd/gist/view/view_test.go
@@ -234,7 +234,7 @@ func Test_viewRun(t *testing.T) {
 					},
 				},
 			},
-			wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n  # foo                                                                           \n\n",
+			wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n  # foo                                                                       \n\n",
 		},
 		{
 			name:  "multiple files, trailing newlines",
@@ -277,7 +277,7 @@ func Test_viewRun(t *testing.T) {
 					},
 				},
 			},
-			wantOut: "some files\n\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n                                                                                  \n  • foo                                                                           \n\n",
+			wantOut: "some files\n\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n                                                                              \n  • foo                                                                       \n\n",
 		},
 		{
 			name:  "multiple files, raw",
diff --git a/pkg/cmd/issue/argparsetest/argparsetest.go b/pkg/cmd/issue/argparsetest/argparsetest.go
new file mode 100644
index 000000000..5ae1ada8d
--- /dev/null
+++ b/pkg/cmd/issue/argparsetest/argparsetest.go
@@ -0,0 +1,137 @@
+package argparsetest
+
+import (
+	"bytes"
+	"reflect"
+	"testing"
+
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/google/shlex"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// newCmdFunc represents the typical function signature we use for creating commands e.g. `NewCmdView`.
+//
+// It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions`
+type newCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command
+
+// TestArgParsing is a test helper that verifies that issue commands correctly parse the `{issue number | url}`
+// positional arg into an issue number and base repo.
+//
+// Looking through the existing tests, I noticed that the coverage was pretty smattered.
+// Since nearly all issue commands only accept a single positional argument, we are able to reuse this test helper.
+// Commands with no further flags or args can use this solely.
+// Commands with extra flags use this and further table tests.
+// Commands with extra required positional arguments (like `transfer`) cannot use this. They duplicate these cases inline.
+func TestArgParsing[T any](t *testing.T, fn newCmdFunc[T]) {
+	tests := []struct {
+		name                string
+		input               string
+		expectedissueNumber int
+		expectedBaseRepo    ghrepo.Interface
+		expectErr           bool
+	}{
+		{
+			name:      "no argument",
+			input:     "",
+			expectErr: true,
+		},
+		{
+			name:                "issue number argument",
+			input:               "23 --repo owner/repo",
+			expectedissueNumber: 23,
+			expectedBaseRepo:    ghrepo.New("owner", "repo"),
+		},
+		{
+			name: "argument is hash prefixed number",
+			// Escaping is required here to avoid what I think is shellex treating it as a comment.
+			input:               "\\#23 --repo owner/repo",
+			expectedissueNumber: 23,
+			expectedBaseRepo:    ghrepo.New("owner", "repo"),
+		},
+		{
+			name:                "argument is a URL",
+			input:               "https://github.com/cli/cli/issues/23",
+			expectedissueNumber: 23,
+			expectedBaseRepo:    ghrepo.New("cli", "cli"),
+		},
+		{
+			name:      "argument cannot be parsed to an issue",
+			input:     "unparseable",
+			expectErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f := &cmdutil.Factory{}
+
+			argv, err := shlex.Split(tt.input)
+			assert.NoError(t, err)
+
+			var gotOpts T
+			cmd := fn(f, func(opts *T) error {
+				gotOpts = *opts
+				return nil
+			})
+
+			cmdutil.EnableRepoOverride(cmd, f)
+
+			// TODO: remember why we do this
+			cmd.Flags().BoolP("help", "x", false, "")
+
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+
+			_, err = cmd.ExecuteC()
+
+			if tt.expectErr {
+				require.Error(t, err)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			actualIssueNumber := issueNumberFromOpts(t, gotOpts)
+			assert.Equal(t, tt.expectedissueNumber, actualIssueNumber)
+
+			actualBaseRepo := baseRepoFromOpts(t, gotOpts)
+			assert.True(
+				t,
+				ghrepo.IsSame(tt.expectedBaseRepo, actualBaseRepo),
+				"expected base repo %+v, got %+v", tt.expectedBaseRepo, actualBaseRepo,
+			)
+		})
+	}
+}
+
+func issueNumberFromOpts(t *testing.T, v any) int {
+	rv := reflect.ValueOf(v)
+	field := rv.FieldByName("IssueNumber")
+	if !field.IsValid() || field.Kind() != reflect.Int {
+		t.Fatalf("Type %T does not have IssueNumber int field", v)
+	}
+	return int(field.Int())
+}
+
+func baseRepoFromOpts(t *testing.T, v any) ghrepo.Interface {
+	rv := reflect.ValueOf(v)
+	field := rv.FieldByName("BaseRepo")
+	// check whether the field is valid and of type func() (ghrepo.Interface, error)
+	if !field.IsValid() || field.Kind() != reflect.Func {
+		t.Fatalf("Type %T does not have BaseRepo func field", v)
+	}
+	// call the function and check the return value
+	results := field.Call([]reflect.Value{})
+	if len(results) != 2 {
+		t.Fatalf("%T.BaseRepo() does not return two values", v)
+	}
+	if !results[1].IsNil() {
+		t.Fatalf("%T.BaseRepo() returned an error: %v", v, results[1].Interface())
+	}
+	return results[0].Interface().(ghrepo.Interface)
+}
diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go
index 4d50e56b2..04c39cd8d 100644
--- a/pkg/cmd/issue/close/close_test.go
+++ b/pkg/cmd/issue/close/close_test.go
@@ -7,46 +7,32 @@ import (
 
 	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestNewCmdClose(t *testing.T) {
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdClose)
+
 	tests := []struct {
-		name    string
-		input   string
-		output  CloseOptions
-		wantErr bool
-		errMsg  string
+		name             string
+		input            string
+		output           CloseOptions
+		expectedBaseRepo ghrepo.Interface
+		wantErr          bool
+		errMsg           string
 	}{
-		{
-			name:    "no argument",
-			input:   "",
-			wantErr: true,
-			errMsg:  "accepts 1 arg(s), received 0",
-		},
-		{
-			name:  "issue number",
-			input: "123",
-			output: CloseOptions{
-				SelectorArg: "123",
-			},
-		},
-		{
-			name:  "issue url",
-			input: "https://github.com/cli/cli/3",
-			output: CloseOptions{
-				SelectorArg: "https://github.com/cli/cli/3",
-			},
-		},
 		{
 			name:  "comment",
 			input: "123 --comment 'closing comment'",
 			output: CloseOptions{
-				SelectorArg: "123",
+				IssueNumber: 123,
 				Comment:     "closing comment",
 			},
 		},
@@ -54,7 +40,7 @@ func TestNewCmdClose(t *testing.T) {
 			name:  "reason",
 			input: "123 --reason 'not planned'",
 			output: CloseOptions{
-				SelectorArg: "123",
+				IssueNumber: 123,
 				Reason:      "not planned",
 			},
 		},
@@ -79,15 +65,24 @@ func TestNewCmdClose(t *testing.T) {
 
 			_, err = cmd.ExecuteC()
 			if tt.wantErr {
-				assert.Error(t, err)
+				require.Error(t, err)
 				assert.Equal(t, tt.errMsg, err.Error())
 				return
 			}
 
-			assert.NoError(t, err)
-			assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
+			require.NoError(t, err)
+			assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber)
 			assert.Equal(t, tt.output.Comment, gotOpts.Comment)
 			assert.Equal(t, tt.output.Reason, gotOpts.Reason)
+			if tt.expectedBaseRepo != nil {
+				baseRepo, err := gotOpts.BaseRepo()
+				require.NoError(t, err)
+				require.True(
+					t,
+					ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
+					"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
+				)
+			}
 		})
 	}
 }
@@ -104,7 +99,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "close issue by number",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
@@ -128,7 +123,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "close issue with comment",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 				Comment:     "closing comment",
 			},
 			httpStubs: func(reg *httpmock.Registry) {
@@ -164,7 +159,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "close issue with reason",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 				Reason:      "not planned",
 				Detector:    &fd.EnabledDetectorMock{},
 			},
@@ -192,7 +187,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "close issue with reason when reason is not supported",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 				Reason:      "not planned",
 				Detector:    &fd.DisabledDetectorMock{},
 			},
@@ -219,7 +214,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "issue already closed",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
@@ -236,7 +231,7 @@ func TestCloseRun(t *testing.T) {
 		{
 			name: "issues disabled",
 			opts: &CloseOptions{
-				SelectorArg: "13",
+				IssueNumber: 13,
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
index 461ae1065..794dafda4 100644
--- a/pkg/cmd/issue/comment/comment_test.go
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -223,9 +223,10 @@ func Test_commentRun(t *testing.T) {
 		httpStubs     func(*testing.T, *httpmock.Registry)
 		stdout        string
 		stderr        string
+		wantsErr      bool
 	}{
 		{
-			name: "interactive editor",
+			name: "creating new comment with interactive editor succeeds",
 			input: &shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
@@ -234,13 +235,29 @@ func Test_commentRun(t *testing.T) {
 				InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
 				ConfirmSubmitSurvey:   func() (bool, error) { return true, nil },
 			},
+			emptyComments: true,
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentCreate(t, reg)
 			},
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 		{
-			name: "interactive editor with edit last",
+			name: "updating last comment with interactive editor fails if there are no comments and decline prompt to create",
+			input: &shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+				EditLast:    true,
+
+				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
+				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
+				ConfirmCreateIfNoneSurvey: func() (bool, error) { return false, nil },
+			},
+			emptyComments: true,
+			wantsErr:      true,
+		},
+		{
+			name: "updating last comment with interactive editor succeeds if there are comments",
 			input: &shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
@@ -253,10 +270,11 @@ func Test_commentRun(t *testing.T) {
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentUpdate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+			emptyComments: false,
+			stdout:        "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
 		},
 		{
-			name: "interactive editor with edit last and create if none",
+			name: "updating last comment with interactive editor creates new comment if there are no comments but --create-if-none",
 			input: &shared.CommentableOptions{
 				Interactive:  true,
 				InputType:    0,
@@ -269,12 +287,14 @@ func Test_commentRun(t *testing.T) {
 				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
-				mockCommentUpdate(t, reg)
+				mockCommentCreate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+			emptyComments: true,
+			stderr:        "No comments found. Creating a new comment.\n",
+			stdout:        "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive web",
+			name: "creating new comment with non-interactive web opens issue in browser focusing on new comment",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeWeb,
@@ -282,55 +302,38 @@ func Test_commentRun(t *testing.T) {
 
 				OpenInBrowser: func(string) error { return nil },
 			},
-			stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
+			emptyComments: true,
+			stderr:        "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
 		},
 		{
-			name: "non-interactive web with edit last",
+			name: "updating last comment with non-interactive web opens issue in browser focusing on the last comment",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeWeb,
 				Body:        "",
 				EditLast:    true,
 
-				OpenInBrowser: func(string) error { return nil },
-			},
-			stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
-		},
-		{
-			name: "non-interactive web with edit last and create if none for empty comments",
-			input: &shared.CommentableOptions{
-				Interactive:  false,
-				InputType:    shared.InputTypeWeb,
-				Body:         "",
-				EditLast:     true,
-				CreateIfNone: true,
-
 				OpenInBrowser: func(u string) error {
-					assert.Contains(t, u, "#issuecomment-new")
+					assert.Contains(t, u, "#issuecomment-111")
 					return nil
 				},
 			},
-			emptyComments: true,
+			emptyComments: false,
 			stderr:        "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
 		},
 		{
-			name: "non-interactive web with edit last and create if none",
+			name: "updating last comment with non-interactive web errors because there are no comments",
 			input: &shared.CommentableOptions{
-				Interactive:  false,
-				InputType:    shared.InputTypeWeb,
-				Body:         "",
-				EditLast:     true,
-				CreateIfNone: true,
-
-				OpenInBrowser: func(u string) error {
-					assert.Contains(t, u, "#issuecomment-111")
-					return nil
-				},
+				Interactive: false,
+				InputType:   shared.InputTypeWeb,
+				Body:        "",
+				EditLast:    true,
 			},
-			stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
+			emptyComments: true,
+			wantsErr:      true,
 		},
 		{
-			name: "non-interactive editor",
+			name: "creating new comment with non-interactive editor succeeds",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeEditor,
@@ -344,7 +347,20 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive editor with edit last",
+			name: "updating last comment with non-interactive editor fails if there are no comments",
+			input: &shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
+				EditLast:    true,
+
+				EditSurvey: func(string) (string, error) { return "comment body", nil },
+			},
+			emptyComments: true,
+			wantsErr:      true,
+		},
+		{
+			name: "updating last comment with non-interactive editor succeeds if there are comments",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeEditor,
@@ -356,10 +372,11 @@ func Test_commentRun(t *testing.T) {
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentUpdate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+			emptyComments: false,
+			stdout:        "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
 		},
 		{
-			name: "non-interactive editor with edit last and create if none",
+			name: "updating last comment with non-interactive editor creates new comment if there are no comments but --create-if-none",
 			input: &shared.CommentableOptions{
 				Interactive:  false,
 				InputType:    shared.InputTypeEditor,
@@ -376,7 +393,7 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive inline",
+			name: "creating new comment with non-interactive inline succeeds if comment body is provided",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeInline,
@@ -388,7 +405,7 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive inline with edit last",
+			name: "updating last comment with non-interactive inline succeeds if there are comments and comment body is provided",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeInline,
@@ -398,7 +415,23 @@ func Test_commentRun(t *testing.T) {
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentUpdate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+			emptyComments: false,
+			stdout:        "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+		},
+		{
+			name: "updating last comment with non-interactive inline creates new comment if there are no comments but --create-if-none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeInline,
+				Body:         "comment body",
+				EditLast:     true,
+				CreateIfNone: true,
+			},
+			emptyComments: true,
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
 		},
 	}
 	for _, tt := range tests {
@@ -437,6 +470,10 @@ func Test_commentRun(t *testing.T) {
 
 		t.Run(tt.name, func(t *testing.T) {
 			err := shared.CommentableRun(tt.input)
+			if tt.wantsErr {
+				assert.Error(t, err)
+				return
+			}
 			assert.NoError(t, err)
 			assert.Equal(t, tt.stdout, stdout.String())
 			assert.Equal(t, tt.stderr, stderr.String())
diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go
index 8e49700a0..1211c0c1d 100644
--- a/pkg/cmd/issue/create/create_test.go
+++ b/pkg/cmd/issue/create/create_test.go
@@ -14,6 +14,7 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/browser"
 	"github.com/cli/cli/v2/internal/config"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/prompter"
@@ -473,6 +474,7 @@ func Test_createRun(t *testing.T) {
 			opts.BaseRepo = func() (ghrepo.Interface, error) {
 				return ghrepo.New("OWNER", "REPO"), nil
 			}
+			opts.Detector = &fd.EnabledDetectorMock{}
 			browser := &browser.Stub{}
 			opts.Browser = browser
 
@@ -521,6 +523,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin
 
 	cmd := NewCmdCreate(factory, func(opts *CreateOptions) error {
 		opts.RootDirOverride = rootDir
+		opts.Detector = &fd.EnabledDetectorMock{}
 		return createRun(opts)
 	})
 
@@ -1026,3 +1029,146 @@ func TestIssueCreate_projectsV2(t *testing.T) {
 
 	assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
 }
+
+// TODO projectsV1Deprecation
+// Remove this test.
+func TestProjectsV1Deprecation(t *testing.T) {
+
+	t.Run("non-interactive submission", 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, ""),
+			)
+
+			_, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			// 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{
+				IO: ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				BaseRepo: func() (ghrepo.Interface, error) {
+					return ghrepo.New("OWNER", "REPO"), nil
+				},
+
+				Detector: &fd.EnabledDetectorMock{},
+				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\(`))
+
+			_, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			// Ignore the error because we're not really interested in it.
+			_ = createRun(&CreateOptions{
+				IO: ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				BaseRepo: func() (ghrepo.Interface, error) {
+					return ghrepo.New("OWNER", "REPO"), nil
+				},
+
+				Detector: &fd.DisabledDetectorMock{},
+				Title:    "Test Title",
+				Body:     "Test Body",
+				// Required to force a lookup of projects
+				Projects: []string{"Project"},
+			})
+
+			// Verify that our request contained 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.Register(
+				// ( is required to avoid matching projectsV2
+				httpmock.GraphQL(`projects\(`),
+				// Simulate a GraphQL error to early exit the test.
+				httpmock.StatusStringResponse(500, ""),
+			)
+
+			_, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			// 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{
+				IO: ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				BaseRepo: func() (ghrepo.Interface, error) {
+					return ghrepo.New("OWNER", "REPO"), nil
+				},
+
+				Detector: &fd.EnabledDetectorMock{},
+				WebMode:  true,
+				// 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{}
+			// ( is required to avoid matching projectsV2
+			reg.Exclude(t, httpmock.GraphQL(`projects\(`))
+
+			_, cmdTeardown := run.Stub()
+			defer cmdTeardown(t)
+
+			// Ignore the error because we're not really interested in it.
+			_ = createRun(&CreateOptions{
+				IO: ios,
+				HttpClient: func() (*http.Client, error) {
+					return &http.Client{Transport: reg}, nil
+				},
+				BaseRepo: func() (ghrepo.Interface, error) {
+					return ghrepo.New("OWNER", "REPO"), nil
+				},
+
+				Detector: &fd.DisabledDetectorMock{},
+				WebMode:  true,
+				// Required to force a lookup of projects
+				Projects: []string{"Project"},
+			})
+
+			// Verify that our request contained projectCards
+			reg.Verify(t)
+		})
+	})
+}
diff --git a/pkg/cmd/issue/delete/delete_test.go b/pkg/cmd/issue/delete/delete_test.go
index bd83c826f..64522b1d3 100644
--- a/pkg/cmd/issue/delete/delete_test.go
+++ b/pkg/cmd/issue/delete/delete_test.go
@@ -12,6 +12,7 @@ import (
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -20,6 +21,10 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestNewCmdDelete(t *testing.T) {
+	argparsetest.TestArgParsing(t, NewCmdDelete)
+}
+
 func runCommand(rt http.RoundTripper, pm *prompter.MockPrompter, isTTY bool, cli string) (*test.CmdOut, error) {
 	ios, _, stdout, stderr := iostreams.Test()
 	ios.SetStdoutTTY(isTTY)
diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go
index 831f03fc3..2485c8cc4 100644
--- a/pkg/cmd/issue/develop/develop_test.go
+++ b/pkg/cmd/issue/develop/develop_test.go
@@ -11,89 +11,74 @@ import (
 	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/run"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestNewCmdDevelop(t *testing.T) {
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdDevelop)
+
 	tests := []struct {
-		name       string
-		input      string
-		output     DevelopOptions
-		wantStdout string
-		wantStderr string
-		wantErr    bool
-		errMsg     string
+		name             string
+		input            string
+		output           DevelopOptions
+		expectedBaseRepo ghrepo.Interface
+		wantStdout       string
+		wantStderr       string
+		wantErr          bool
+		errMsg           string
 	}{
-		{
-			name:    "no argument",
-			input:   "",
-			output:  DevelopOptions{},
-			wantErr: true,
-			errMsg:  "issue number or url is required",
-		},
-		{
-			name:  "issue number",
-			input: "1",
-			output: DevelopOptions{
-				IssueSelector: "1",
-			},
-		},
-		{
-			name:  "issue url",
-			input: "https://github.com/cli/cli/issues/1",
-			output: DevelopOptions{
-				IssueSelector: "https://github.com/cli/cli/issues/1",
-			},
-		},
 		{
 			name:  "branch-repo flag",
 			input: "1 --branch-repo owner/repo",
 			output: DevelopOptions{
-				IssueSelector: "1",
-				BranchRepo:    "owner/repo",
+				IssueNumber: 1,
+				BranchRepo:  "owner/repo",
 			},
 		},
 		{
 			name:  "base flag",
 			input: "1 --base feature",
 			output: DevelopOptions{
-				IssueSelector: "1",
-				BaseBranch:    "feature",
+				IssueNumber: 1,
+				BaseBranch:  "feature",
 			},
 		},
 		{
 			name:  "checkout flag",
 			input: "1 --checkout",
 			output: DevelopOptions{
-				IssueSelector: "1",
-				Checkout:      true,
+				IssueNumber: 1,
+				Checkout:    true,
 			},
 		},
 		{
 			name:  "list flag",
 			input: "1 --list",
 			output: DevelopOptions{
-				IssueSelector: "1",
-				List:          true,
+				IssueNumber: 1,
+				List:        true,
 			},
 		},
 		{
 			name:  "name flag",
 			input: "1 --name feature",
 			output: DevelopOptions{
-				IssueSelector: "1",
-				Name:          "feature",
+				IssueNumber: 1,
+				Name:        "feature",
 			},
 		},
 		{
 			name:  "issue-repo flag",
 			input: "1 --issue-repo cli/cli",
 			output: DevelopOptions{
-				IssueSelector: "1",
+				IssueNumber: 1,
 			},
 			wantStdout: "Flag --issue-repo has been deprecated, use `--repo` instead\n",
 		},
@@ -143,18 +128,27 @@ func TestNewCmdDevelop(t *testing.T) {
 
 			_, err = cmd.ExecuteC()
 			if tt.wantErr {
-				assert.EqualError(t, err, tt.errMsg)
+				require.EqualError(t, err, tt.errMsg)
 				return
 			}
 
-			assert.NoError(t, err)
-			assert.Equal(t, tt.output.IssueSelector, gotOpts.IssueSelector)
+			require.NoError(t, err)
+			assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber)
 			assert.Equal(t, tt.output.Name, gotOpts.Name)
 			assert.Equal(t, tt.output.BaseBranch, gotOpts.BaseBranch)
 			assert.Equal(t, tt.output.Checkout, gotOpts.Checkout)
 			assert.Equal(t, tt.output.List, gotOpts.List)
 			assert.Equal(t, tt.wantStdout, stdOut.String())
 			assert.Equal(t, tt.wantStderr, stdErr.String())
+			if tt.expectedBaseRepo != nil {
+				baseRepo, err := gotOpts.BaseRepo()
+				require.NoError(t, err)
+				require.True(
+					t,
+					ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
+					"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
+				)
+			}
 		})
 	}
 }
@@ -178,8 +172,8 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "returns an error when the feature is not supported by the API",
 			opts: &DevelopOptions{
-				IssueSelector: "42",
-				List:          true,
+				IssueNumber: 42,
+				List:        true,
 			},
 			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
 				reg.Register(
@@ -196,8 +190,8 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "list branches for an issue",
 			opts: &DevelopOptions{
-				IssueSelector: "42",
-				List:          true,
+				IssueNumber: 42,
+				List:        true,
 			},
 			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
 				reg.Register(
@@ -223,8 +217,8 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "list branches for an issue in tty",
 			opts: &DevelopOptions{
-				IssueSelector: "42",
-				List:          true,
+				IssueNumber: 42,
+				List:        true,
 			},
 			tty: true,
 			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
@@ -255,37 +249,10 @@ func TestDevelopRun(t *testing.T) {
 				bar     https://github.com/OWNER/OTHER-REPO/tree/bar
 			`),
 		},
-		{
-			name: "list branches for an issue providing an issue url",
-			opts: &DevelopOptions{
-				IssueSelector: "https://github.com/cli/cli/issues/42",
-				List:          true,
-			},
-			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
-				reg.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`),
-				)
-				reg.Register(
-					httpmock.GraphQL(`query LinkedBranchFeature\b`),
-					httpmock.StringResponse(featureEnabledPayload),
-				)
-				reg.Register(
-					httpmock.GraphQL(`query ListLinkedBranches\b`),
-					httpmock.GraphQLQuery(`
-		        {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}}
-					`, func(query string, inputs map[string]interface{}) {
-						assert.Equal(t, float64(42), inputs["number"])
-						assert.Equal(t, "cli", inputs["owner"])
-						assert.Equal(t, "cli", inputs["name"])
-					}))
-			},
-			expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/OTHER-REPO/tree/bar\n",
-		},
 		{
 			name: "develop new branch",
 			opts: &DevelopOptions{
-				IssueSelector: "123",
+				IssueNumber: 123,
 			},
 			remotes: map[string]string{
 				"origin": "OWNER/REPO",
@@ -321,8 +288,8 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop new branch in different repo than issue",
 			opts: &DevelopOptions{
-				IssueSelector: "123",
-				BranchRepo:    "OWNER2/REPO",
+				IssueNumber: 123,
+				BranchRepo:  "OWNER2/REPO",
 			},
 			remotes: map[string]string{
 				"origin": "OWNER2/REPO",
@@ -367,9 +334,9 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop new branch with name and base specified",
 			opts: &DevelopOptions{
-				Name:          "my-branch",
-				BaseBranch:    "main",
-				IssueSelector: "123",
+				Name:        "my-branch",
+				BaseBranch:  "main",
+				IssueNumber: 123,
 			},
 			remotes: map[string]string{
 				"origin": "OWNER/REPO",
@@ -406,7 +373,10 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop new branch outside of local git repo",
 			opts: &DevelopOptions{
-				IssueSelector: "https://github.com/cli/cli/issues/123",
+				IssueNumber: 123,
+				BaseRepo: func() (ghrepo.Interface, error) {
+					return ghrepo.New("cli", "cli"), nil
+				},
 			},
 			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
 				reg.Register(
@@ -436,9 +406,9 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop new branch with checkout when local branch exists",
 			opts: &DevelopOptions{
-				Name:          "my-branch",
-				IssueSelector: "123",
-				Checkout:      true,
+				Name:        "my-branch",
+				IssueNumber: 123,
+				Checkout:    true,
 			},
 			remotes: map[string]string{
 				"origin": "OWNER/REPO",
@@ -478,9 +448,9 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop new branch with checkout when local branch does not exist",
 			opts: &DevelopOptions{
-				Name:          "my-branch",
-				IssueSelector: "123",
-				Checkout:      true,
+				Name:        "my-branch",
+				IssueNumber: 123,
+				Checkout:    true,
 			},
 			remotes: map[string]string{
 				"origin": "OWNER/REPO",
@@ -519,8 +489,8 @@ func TestDevelopRun(t *testing.T) {
 		{
 			name: "develop with base branch which does not exist",
 			opts: &DevelopOptions{
-				IssueSelector: "123",
-				BaseBranch:    "does-not-exist-branch",
+				IssueNumber: 123,
+				BaseBranch:  "does-not-exist-branch",
 			},
 			remotes: map[string]string{
 				"origin": "OWNER/REPO",
@@ -561,8 +531,10 @@ func TestDevelopRun(t *testing.T) {
 			ios.SetStderrTTY(tt.tty)
 			opts.IO = ios
 
-			opts.BaseRepo = func() (ghrepo.Interface, error) {
-				return ghrepo.New("OWNER", "REPO"), nil
+			if opts.BaseRepo == nil {
+				opts.BaseRepo = func() (ghrepo.Interface, error) {
+					return ghrepo.New("OWNER", "REPO"), nil
+				}
 			}
 
 			opts.Remotes = func() (context.Remotes, error) {
diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go
index 40fe6491c..c9aa4c409 100644
--- a/pkg/cmd/issue/edit/edit_test.go
+++ b/pkg/cmd/issue/edit/edit_test.go
@@ -10,7 +10,9 @@ 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/ghrepo"
+	"github.com/cli/cli/v2/internal/run"
 	prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
@@ -26,11 +28,12 @@ func TestNewCmdEdit(t *testing.T) {
 	require.NoError(t, err)
 
 	tests := []struct {
-		name     string
-		input    string
-		stdin    string
-		output   EditOptions
-		wantsErr bool
+		name             string
+		input            string
+		stdin            string
+		output           EditOptions
+		expectedBaseRepo ghrepo.Interface
+		wantsErr         bool
 	}{
 		{
 			name:     "no argument",
@@ -42,7 +45,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "issue number argument",
 			input: "23",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Interactive:  true,
 			},
 			wantsErr: false,
@@ -51,7 +54,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "title flag",
 			input: "23 --title test",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Title: prShared.EditableString{
 						Value:  "test",
@@ -65,7 +68,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "body flag",
 			input: "23 --body test",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Body: prShared.EditableString{
 						Value:  "test",
@@ -80,7 +83,7 @@ func TestNewCmdEdit(t *testing.T) {
 			input: "23 --body-file -",
 			stdin: "this is on standard input",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Body: prShared.EditableString{
 						Value:  "this is on standard input",
@@ -94,7 +97,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "body from file",
 			input: fmt.Sprintf("23 --body-file '%s'", tmpFile),
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Body: prShared.EditableString{
 						Value:  "a body from file",
@@ -113,7 +116,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "add-assignee flag",
 			input: "23 --add-assignee monalisa,hubot",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Assignees: prShared.EditableSlice{
 						Add:    []string{"monalisa", "hubot"},
@@ -127,7 +130,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "remove-assignee flag",
 			input: "23 --remove-assignee monalisa,hubot",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Assignees: prShared.EditableSlice{
 						Remove: []string{"monalisa", "hubot"},
@@ -141,7 +144,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "add-label flag",
 			input: "23 --add-label feature,TODO,bug",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Labels: prShared.EditableSlice{
 						Add:    []string{"feature", "TODO", "bug"},
@@ -155,7 +158,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "remove-label flag",
 			input: "23 --remove-label feature,TODO,bug",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Labels: prShared.EditableSlice{
 						Remove: []string{"feature", "TODO", "bug"},
@@ -169,7 +172,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "add-project flag",
 			input: "23 --add-project Cleanup,Roadmap",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Projects: prShared.EditableProjects{
 						EditableSlice: prShared.EditableSlice{
@@ -185,7 +188,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "remove-project flag",
 			input: "23 --remove-project Cleanup,Roadmap",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Projects: prShared.EditableProjects{
 						EditableSlice: prShared.EditableSlice{
@@ -201,7 +204,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "milestone flag",
 			input: "23 --milestone GA",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Milestone: prShared.EditableString{
 						Value:  "GA",
@@ -215,7 +218,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "remove-milestone flag",
 			input: "23 --remove-milestone",
 			output: EditOptions{
-				SelectorArgs: []string{"23"},
+				IssueNumbers: []int{23},
 				Editable: prShared.Editable{
 					Milestone: prShared.EditableString{
 						Value:  "",
@@ -234,7 +237,7 @@ func TestNewCmdEdit(t *testing.T) {
 			name:  "add label to multiple issues",
 			input: "23 34 --add-label bug",
 			output: EditOptions{
-				SelectorArgs: []string{"23", "34"},
+				IssueNumbers: []int{23, 34},
 				Editable: prShared.Editable{
 					Labels: prShared.EditableSlice{
 						Add:    []string{"bug"},
@@ -244,6 +247,31 @@ func TestNewCmdEdit(t *testing.T) {
 			},
 			wantsErr: false,
 		},
+		{
+			name: "argument is hash prefixed number",
+			// Escaping is required here to avoid what I think is shellex treating it as a comment.
+			input: "\\#23",
+			output: EditOptions{
+				IssueNumbers: []int{23},
+				Interactive:  true,
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "argument is a URL",
+			input: "https://github.com/cli/cli/issues/23",
+			output: EditOptions{
+				IssueNumbers: []int{23},
+				Interactive:  true,
+			},
+			expectedBaseRepo: ghrepo.New("cli", "cli"),
+			wantsErr:         false,
+		},
+		{
+			name:     "URL arguments parse as different repos",
+			input:    "https://github.com/cli/cli/issues/23 https://github.com/cli/go-gh/issues/23",
+			wantsErr: true,
+		},
 		{
 			name:     "interactive multiple issues",
 			input:    "23 34",
@@ -282,14 +310,23 @@ func TestNewCmdEdit(t *testing.T) {
 
 			_, err = cmd.ExecuteC()
 			if tt.wantsErr {
-				assert.Error(t, err)
+				require.Error(t, err)
 				return
 			}
 
-			assert.NoError(t, err)
-			assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs)
+			require.NoError(t, err)
+			assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers)
 			assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
 			assert.Equal(t, tt.output.Editable, gotOpts.Editable)
+			if tt.expectedBaseRepo != nil {
+				baseRepo, err := gotOpts.BaseRepo()
+				require.NoError(t, err)
+				require.True(
+					t,
+					ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
+					"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
+				)
+			}
 		})
 	}
 }
@@ -306,7 +343,7 @@ func Test_editRun(t *testing.T) {
 		{
 			name: "non-interactive",
 			input: &EditOptions{
-				SelectorArgs: []string{"123"},
+				IssueNumbers: []int{123},
 				Interactive:  false,
 				Editable: prShared.Editable{
 					Title: prShared.EditableString{
@@ -359,7 +396,7 @@ func Test_editRun(t *testing.T) {
 		{
 			name: "non-interactive multiple issues",
 			input: &EditOptions{
-				SelectorArgs: []string{"456", "123"},
+				IssueNumbers: []int{456, 123},
 				Interactive:  false,
 				Editable: prShared.Editable{
 					Assignees: prShared.EditableSlice{
@@ -409,7 +446,7 @@ func Test_editRun(t *testing.T) {
 		{
 			name: "non-interactive multiple issues with fetch failures",
 			input: &EditOptions{
-				SelectorArgs: []string{"123", "9999"},
+				IssueNumbers: []int{123, 9999},
 				Interactive:  false,
 				Editable: prShared.Editable{
 					Assignees: prShared.EditableSlice{
@@ -454,7 +491,7 @@ func Test_editRun(t *testing.T) {
 		{
 			name: "non-interactive multiple issues with update failures",
 			input: &EditOptions{
-				SelectorArgs: []string{"123", "456"},
+				IssueNumbers: []int{123, 456},
 				Interactive:  false,
 				Editable: prShared.Editable{
 					Assignees: prShared.EditableSlice{
@@ -524,7 +561,7 @@ func Test_editRun(t *testing.T) {
 		{
 			name: "interactive",
 			input: &EditOptions{
-				SelectorArgs: []string{"123"},
+				IssueNumbers: []int{123},
 				Interactive:  true,
 				FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
 					eo.Title.Edited = true
@@ -753,3 +790,88 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) {
 			func(inputs map[string]interface{}) {}),
 	)
 }
+
+// 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, ""),
+		)
+
+		_, cmdTeardown := run.Stub()
+		defer cmdTeardown(t)
+
+		// 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
+			},
+			BaseRepo: func() (ghrepo.Interface, error) {
+				return ghrepo.New("OWNER", "REPO"), nil
+			},
+			Detector: &fd.EnabledDetectorMock{},
+
+			IssueNumbers: []int{123},
+			Editable: prShared.Editable{
+				Projects: prShared.EditableProjects{
+					EditableSlice: prShared.EditableSlice{
+						Add:    []string{"Test Project"},
+						Edited: true,
+					},
+				},
+			},
+		})
+
+		// 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`))
+
+		reg.Register(
+			httpmock.GraphQL(`.*`),
+			// Simulate a GraphQL error to early exit the test.
+			httpmock.StatusStringResponse(500, ""),
+		)
+
+		_, cmdTeardown := run.Stub()
+		defer cmdTeardown(t)
+
+		// Ignore the error because we're not really interested in it.
+		_ = editRun(&EditOptions{
+			IO: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+			BaseRepo: func() (ghrepo.Interface, error) {
+				return ghrepo.New("OWNER", "REPO"), nil
+			},
+			Detector: &fd.DisabledDetectorMock{},
+
+			IssueNumbers: []int{123},
+			Editable: prShared.Editable{
+				Projects: prShared.EditableProjects{
+					EditableSlice: prShared.EditableSlice{
+						Add:    []string{"Test Project"},
+						Edited: true,
+					},
+				},
+			},
+		})
+
+		// Verify that our request contained projectCards
+		reg.Verify(t)
+	})
+}
diff --git a/pkg/cmd/issue/lock/lock_test.go b/pkg/cmd/issue/lock/lock_test.go
index f6dcb746d..1ca320f35 100644
--- a/pkg/cmd/issue/lock/lock_test.go
+++ b/pkg/cmd/issue/lock/lock_test.go
@@ -30,7 +30,7 @@ func Test_NewCmdLock(t *testing.T) {
 			args: "--reason off_topic 451",
 			want: LockOptions{
 				Reason:      "off_topic",
-				SelectorArg: "451",
+				IssueNumber: 451,
 			},
 		},
 		{
@@ -41,9 +41,36 @@ func Test_NewCmdLock(t *testing.T) {
 			name: "no flags",
 			args: "451",
 			want: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 			},
 		},
+		{
+			name: "issue number argument",
+			args: "451 --repo owner/repo",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name: "argument is hash prefixed number",
+			// Escaping is required here to avoid what I think is shellex treating it as a comment.
+			args: "\\#451 --repo owner/repo",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name: "argument is a URL",
+			args: "https://github.com/cli/cli/issues/451",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name:    "argument cannot be parsed to an issue",
+			args:    "unparseable",
+			wantErr: "invalid issue format: \"unparseable\"",
+		},
 		{
 			name:    "bad reason",
 			args:    "--reason bad 451",
@@ -60,7 +87,7 @@ func Test_NewCmdLock(t *testing.T) {
 			args: "451",
 			tty:  true,
 			want: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				Interactive: true,
 			},
 		},
@@ -99,7 +126,7 @@ func Test_NewCmdLock(t *testing.T) {
 			}
 
 			assert.Equal(t, tt.want.Reason, opts.Reason)
-			assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
+			assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber)
 			assert.Equal(t, tt.want.Interactive, opts.Interactive)
 		})
 	}
@@ -121,9 +148,36 @@ func Test_NewCmdUnlock(t *testing.T) {
 			name: "no flags",
 			args: "451",
 			want: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 			},
 		},
+		{
+			name: "issue number argument",
+			args: "451 --repo owner/repo",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name: "argument is hash prefixed number",
+			// Escaping is required here to avoid what I think is shellex treating it as a comment.
+			args: "\\#451 --repo owner/repo",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name: "argument is a URL",
+			args: "https://github.com/cli/cli/issues/451",
+			want: LockOptions{
+				IssueNumber: 451,
+			},
+		},
+		{
+			name:    "argument cannot be parsed to an issue",
+			args:    "unparseable",
+			wantErr: "invalid issue format: \"unparseable\"",
+		},
 	}
 
 	for _, tt := range cases {
@@ -158,7 +212,7 @@ func Test_NewCmdUnlock(t *testing.T) {
 				assert.NoError(t, err)
 			}
 
-			assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
+			assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber)
 		})
 	}
 }
@@ -179,7 +233,7 @@ func Test_runLock(t *testing.T) {
 			name:  "lock issue nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -203,7 +257,7 @@ func Test_runLock(t *testing.T) {
 			tty:  true,
 			opts: LockOptions{
 				Interactive: true,
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 			},
 			state: Lock,
@@ -241,7 +295,7 @@ func Test_runLock(t *testing.T) {
 			tty:   true,
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 				Reason:      "off_topic",
 			},
@@ -268,7 +322,7 @@ func Test_runLock(t *testing.T) {
 			tty:   true,
 			state: Unlock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -294,7 +348,7 @@ func Test_runLock(t *testing.T) {
 			name:  "unlock issue nontty",
 			state: Unlock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -319,7 +373,7 @@ func Test_runLock(t *testing.T) {
 			name:  "lock issue with explicit reason nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 				Reason:      "off_topic",
 			},
@@ -344,7 +398,7 @@ func Test_runLock(t *testing.T) {
 			name:  "relock issue tty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 				Reason:      "off_topic",
 			},
@@ -388,7 +442,7 @@ func Test_runLock(t *testing.T) {
 			name:  "relock issue nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "issue",
 				Reason:      "off_topic",
 			},
@@ -409,7 +463,7 @@ func Test_runLock(t *testing.T) {
 			name:  "lock pr nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -433,7 +487,7 @@ func Test_runLock(t *testing.T) {
 			tty:  true,
 			opts: LockOptions{
 				Interactive: true,
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 			},
 			state: Lock,
@@ -469,7 +523,7 @@ func Test_runLock(t *testing.T) {
 			tty:   true,
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 				Reason:      "off_topic",
 			},
@@ -495,7 +549,7 @@ func Test_runLock(t *testing.T) {
 			name:  "lock pr with explicit nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 				Reason:      "off_topic",
 			},
@@ -520,7 +574,7 @@ func Test_runLock(t *testing.T) {
 			name:  "unlock pr tty",
 			state: Unlock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -546,7 +600,7 @@ func Test_runLock(t *testing.T) {
 			tty:   true,
 			state: Unlock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -572,7 +626,7 @@ func Test_runLock(t *testing.T) {
 			name:  "relock pr tty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 				Reason:      "off_topic",
 			},
@@ -616,7 +670,7 @@ func Test_runLock(t *testing.T) {
 			name:  "relock pr nontty",
 			state: Lock,
 			opts: LockOptions{
-				SelectorArg: "451",
+				IssueNumber: 451,
 				ParentCmd:   "pr",
 				Reason:      "off_topic",
 			},
diff --git a/pkg/cmd/issue/pin/pin_test.go b/pkg/cmd/issue/pin/pin_test.go
index d4979a30d..67b767b32 100644
--- a/pkg/cmd/issue/pin/pin_test.go
+++ b/pkg/cmd/issue/pin/pin_test.go
@@ -1,80 +1,21 @@
 package pin
 
 import (
-	"bytes"
 	"net/http"
 	"testing"
 
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
-	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestNewCmdPin(t *testing.T) {
-	tests := []struct {
-		name    string
-		input   string
-		output  PinOptions
-		wantErr bool
-		errMsg  string
-	}{
-		{
-			name:    "no argument",
-			input:   "",
-			wantErr: true,
-			errMsg:  "accepts 1 arg(s), received 0",
-		},
-		{
-			name:  "issue number",
-			input: "6",
-			output: PinOptions{
-				SelectorArg: "6",
-			},
-		},
-		{
-			name:  "issue url",
-			input: "https://github.com/cli/cli/6",
-			output: PinOptions{
-				SelectorArg: "https://github.com/cli/cli/6",
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			ios, _, _, _ := iostreams.Test()
-			ios.SetStdinTTY(true)
-			ios.SetStdoutTTY(true)
-			f := &cmdutil.Factory{
-				IOStreams: ios,
-			}
-			argv, err := shlex.Split(tt.input)
-			assert.NoError(t, err)
-			var gotOpts *PinOptions
-			cmd := NewCmdPin(f, func(opts *PinOptions) error {
-				gotOpts = opts
-				return nil
-			})
-			cmd.SetArgs(argv)
-			cmd.SetIn(&bytes.Buffer{})
-			cmd.SetOut(&bytes.Buffer{})
-			cmd.SetErr(&bytes.Buffer{})
-
-			_, err = cmd.ExecuteC()
-			if tt.wantErr {
-				assert.Error(t, err)
-				assert.Equal(t, tt.errMsg, err.Error())
-				return
-			}
-
-			assert.NoError(t, err)
-			assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
-		})
-	}
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdPin)
 }
 
 func TestPinRun(t *testing.T) {
@@ -89,7 +30,7 @@ func TestPinRun(t *testing.T) {
 		{
 			name: "pin issue",
 			tty:  true,
-			opts: &PinOptions{SelectorArg: "20"},
+			opts: &PinOptions{IssueNumber: 20},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
@@ -113,7 +54,7 @@ func TestPinRun(t *testing.T) {
 		{
 			name: "issue already pinned",
 			tty:  true,
-			opts: &PinOptions{SelectorArg: "20"},
+			opts: &PinOptions{IssueNumber: 20},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go
index 4b8b33ee1..f7c8cb95a 100644
--- a/pkg/cmd/issue/reopen/reopen_test.go
+++ b/pkg/cmd/issue/reopen/reopen_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -18,6 +19,11 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestNewCmdReopen(t *testing.T) {
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdReopen)
+}
+
 func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
 	ios, _, stdout, stderr := iostreams.Test()
 	ios.SetStdoutTTY(isTTY)
diff --git a/pkg/cmd/issue/shared/lookup_test.go b/pkg/cmd/issue/shared/lookup_test.go
index 44f496de4..f921ca49b 100644
--- a/pkg/cmd/issue/shared/lookup_test.go
+++ b/pkg/cmd/issue/shared/lookup_test.go
@@ -2,240 +2,94 @@ package shared
 
 import (
 	"net/http"
-	"strings"
 	"testing"
 
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/httpmock"
+	o "github.com/cli/cli/v2/pkg/option"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
-func TestIssueFromArgWithFields(t *testing.T) {
-	type args struct {
-		baseRepoFn func() (ghrepo.Interface, error)
-		selector   string
-	}
+func TestParseIssuesFromArgs(t *testing.T) {
 	tests := []struct {
-		name         string
-		args         args
-		httpStub     func(*httpmock.Registry)
-		wantIssue    int
-		wantRepo     string
-		wantProjects string
-		wantErr      bool
+		behavior             string
+		args                 []string
+		expectedIssueNumbers []int
+		expectedRepo         o.Option[ghrepo.Interface]
+		expectedErr          bool
 	}{
 		{
-			name: "number argument",
-			args: args{
-				selector: "13",
-				baseRepoFn: func() (ghrepo.Interface, error) {
-					return ghrepo.FromFullName("OWNER/REPO")
-				},
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{
-						"hasIssuesEnabled": true,
-						"issue":{"number":13}
-					}}}`))
-			},
-			wantIssue: 13,
-			wantRepo:  "https://github.com/OWNER/REPO",
+			behavior:             "when given issue numbers, returns them with no repo",
+			args:                 []string{"1", "2"},
+			expectedIssueNumbers: []int{1, 2},
+			expectedRepo:         o.None[ghrepo.Interface](),
 		},
 		{
-			name: "number with hash argument",
-			args: args{
-				selector: "#13",
-				baseRepoFn: func() (ghrepo.Interface, error) {
-					return ghrepo.FromFullName("OWNER/REPO")
-				},
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{
-						"hasIssuesEnabled": true,
-						"issue":{"number":13}
-					}}}`))
-			},
-			wantIssue: 13,
-			wantRepo:  "https://github.com/OWNER/REPO",
+			behavior:             "when given # prefixed issue numbers, returns them with no repo",
+			args:                 []string{"#1", "#2"},
+			expectedIssueNumbers: []int{1, 2},
+			expectedRepo:         o.None[ghrepo.Interface](),
 		},
 		{
-			name: "URL argument",
-			args: args{
-				selector:   "https://example.org/OWNER/REPO/issues/13#comment-123",
-				baseRepoFn: nil,
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{
-						"hasIssuesEnabled": true,
-						"issue":{"number":13}
-					}}}`))
+			behavior: "when given URLs, returns them with the repo",
+			args: []string{
+				"https://github.com/OWNER/REPO/issues/1",
+				"https://github.com/OWNER/REPO/issues/2",
 			},
-			wantIssue: 13,
-			wantRepo:  "https://example.org/OWNER/REPO",
+			expectedIssueNumbers: []int{1, 2},
+			expectedRepo:         o.Some(ghrepo.New("OWNER", "REPO")),
 		},
 		{
-			name: "PR URL argument",
-			args: args{
-				selector:   "https://example.org/OWNER/REPO/pull/13#comment-123",
-				baseRepoFn: nil,
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{
-						"hasIssuesEnabled": true,
-						"issue":{"number":13}
-					}}}`))
+			behavior: "when given URLs in different repos, errors",
+			args: []string{
+				"https://github.com/OWNER/REPO/issues/1",
+				"https://github.com/OWNER/OTHERREPO/issues/2",
 			},
-			wantIssue: 13,
-			wantRepo:  "https://example.org/OWNER/REPO",
+			expectedErr: true,
 		},
 		{
-			name: "project cards permission issue",
-			args: args{
-				selector:   "https://example.org/OWNER/REPO/issues/13",
-				baseRepoFn: nil,
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`
-					{
-						"data": {
-							"repository": {
-								"hasIssuesEnabled": true,
-								"issue": {
-									"number": 13,
-									"projectCards": {
-										"nodes": [
-											null,
-											{
-												"project": {"name": "myproject"},
-												"column": {"name": "To Do"}
-											},
-											null,
-											{
-												"project": {"name": "other project"},
-												"column": null
-											}
-										]
-									}
-								}
-							}
-						},
-						"errors": [
-							{
-								"type": "FORBIDDEN",
-								"message": "Resource not accessible by integration",
-								"path": ["repository", "issue", "projectCards", "nodes", 0]
-							},
-							{
-								"type": "FORBIDDEN",
-								"message": "Resource not accessible by integration",
-								"path": ["repository", "issue", "projectCards", "nodes", 2]
-							}
-						]
-					}`))
-			},
-			wantErr:      true,
-			wantIssue:    13,
-			wantProjects: "myproject, other project",
-			wantRepo:     "https://example.org/OWNER/REPO",
+			behavior:    "when given an unparseable argument, errors",
+			args:        []string{"://"},
+			expectedErr: true,
 		},
 		{
-			name: "projects permission issue",
-			args: args{
-				selector:   "https://example.org/OWNER/REPO/issues/13",
-				baseRepoFn: nil,
-			},
-			httpStub: func(r *httpmock.Registry) {
-				r.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`
-					{
-						"data": {
-							"repository": {
-								"hasIssuesEnabled": true,
-								"issue": {
-									"number": 13,
-									"projectCards": {
-										"nodes": null,
-										"totalCount": 0
-									}
-								}
-							}
-						},
-						"errors": [
-							{
-								"type": "FORBIDDEN",
-								"message": "Resource not accessible by integration",
-								"path": ["repository", "issue", "projectCards", "nodes"]
-							}
-						]
-					}`))
-			},
-			wantErr:      true,
-			wantIssue:    13,
-			wantProjects: "",
-			wantRepo:     "https://example.org/OWNER/REPO",
+			behavior:    "when given a URL that isn't an issue or PR url, errors",
+			args:        []string{"https://github.com"},
+			expectedErr: true,
 		},
 	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			reg := &httpmock.Registry{}
-			if tt.httpStub != nil {
-				tt.httpStub(reg)
-			}
-			httpClient := &http.Client{Transport: reg}
-			issue, repo, err := IssueFromArgWithFields(httpClient, tt.args.baseRepoFn, tt.args.selector, []string{"number"})
-			if (err != nil) != tt.wantErr {
-				t.Errorf("IssueFromArgWithFields() error = %v, wantErr %v", err, tt.wantErr)
-				if issue == nil {
-					return
-				}
-			}
-			if issue.Number != tt.wantIssue {
-				t.Errorf("want issue #%d, got #%d", tt.wantIssue, issue.Number)
-			}
-			if gotProjects := strings.Join(issue.ProjectCards.ProjectNames(), ", "); gotProjects != tt.wantProjects {
-				t.Errorf("want projects %q, got %q", tt.wantProjects, gotProjects)
-			}
-			repoURL := ghrepo.GenerateRepoURL(repo, "")
-			if repoURL != tt.wantRepo {
-				t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
+
+	for _, tc := range tests {
+		t.Run(tc.behavior, func(t *testing.T) {
+			issueNumbers, repo, err := ParseIssuesFromArgs(tc.args)
+
+			if tc.expectedErr {
+				require.Error(t, err)
+				return
 			}
+
+			require.NoError(t, err)
+			assert.Equal(t, tc.expectedIssueNumbers, issueNumbers)
+			assert.Equal(t, tc.expectedRepo, repo)
 		})
 	}
+
 }
 
-func TestIssuesFromArgsWithFields(t *testing.T) {
-	type args struct {
-		baseRepoFn func() (ghrepo.Interface, error)
-		selectors  []string
-	}
+func TestFindIssuesOrPRs(t *testing.T) {
 	tests := []struct {
-		name       string
-		args       args
-		httpStub   func(*httpmock.Registry)
-		wantIssues []int
-		wantRepo   string
-		wantErr    bool
-		wantErrMsg string
+		name             string
+		issueNumbers     []int
+		baseRepo         ghrepo.Interface
+		httpStub         func(*httpmock.Registry)
+		wantIssueNumbers []int
+		wantErr          bool
 	}{
 		{
-			name: "multiple repos",
-			args: args{
-				selectors: []string{"1", "https://github.com/OWNER/OTHERREPO/issues/2"},
-				baseRepoFn: func() (ghrepo.Interface, error) {
-					return ghrepo.New("OWNER", "REPO"), nil
-				},
-			},
+			name:         "multiple issues",
+			issueNumbers: []int{1, 2},
+			baseRepo:     ghrepo.New("OWNER", "REPO"),
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
@@ -248,19 +102,14 @@ func TestIssuesFromArgsWithFields(t *testing.T) {
 					httpmock.StringResponse(`{"data":{"repository":{
 							"hasIssuesEnabled": true,
 							"issue":{"number":2}
-					}}}`))
+						}}}`))
 			},
-			wantErr:    true,
-			wantErrMsg: "multiple issues must be in same repo",
+			wantIssueNumbers: []int{1, 2},
 		},
 		{
-			name: "multiple issues",
-			args: args{
-				selectors: []string{"1", "2"},
-				baseRepoFn: func() (ghrepo.Interface, error) {
-					return ghrepo.New("OWNER", "REPO"), nil
-				},
-			},
+			name:         "any find error results in total error",
+			issueNumbers: []int{1, 2},
+			baseRepo:     ghrepo.New("OWNER", "REPO"),
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
@@ -270,48 +119,33 @@ func TestIssuesFromArgsWithFields(t *testing.T) {
 					}}}`))
 				r.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(`{"data":{"repository":{
-							"hasIssuesEnabled": true,
-							"issue":{"number":2}
-						}}}`))
+					httpmock.StatusStringResponse(500, "internal server error"))
 			},
-			wantIssues: []int{1, 2},
-			wantRepo:   "https://github.com/OWNER/REPO",
+			wantErr: true,
 		},
 	}
 	for _, tt := range tests {
-		if !tt.wantErr && len(tt.args.selectors) != len(tt.wantIssues) {
-			t.Fatal("number of selectors and issues not equal")
-		}
 		t.Run(tt.name, func(t *testing.T) {
 			reg := &httpmock.Registry{}
 			if tt.httpStub != nil {
 				tt.httpStub(reg)
 			}
 			httpClient := &http.Client{Transport: reg}
-			issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"})
+			issues, err := FindIssuesOrPRs(httpClient, tt.baseRepo, tt.issueNumbers, []string{"number"})
 			if (err != nil) != tt.wantErr {
-				t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr)
+				t.Errorf("FindIssuesOrPRs() error = %v, wantErr %v", err, tt.wantErr)
 				if issues == nil {
 					return
 				}
 			}
 			if tt.wantErr {
-				assert.Error(t, err)
-				assert.ErrorContains(t, err, tt.wantErrMsg)
+				require.Error(t, err)
 				return
 			}
-			assert.NoError(t, err)
+
+			require.NoError(t, err)
 			for i := range issues {
-				assert.Contains(t, tt.wantIssues, issues[i].Number)
-			}
-			if repo != nil {
-				repoURL := ghrepo.GenerateRepoURL(repo, "")
-				if repoURL != tt.wantRepo {
-					t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
-				}
-			} else if tt.wantRepo != "" {
-				t.Errorf("want repo %sw, got nil", tt.wantRepo)
+				assert.Contains(t, tt.wantIssueNumbers, issues[i].Number)
 			}
 		})
 	}
diff --git a/pkg/cmd/issue/transfer/transfer_test.go b/pkg/cmd/issue/transfer/transfer_test.go
index eed9c5d85..2b12db944 100644
--- a/pkg/cmd/issue/transfer/transfer_test.go
+++ b/pkg/cmd/issue/transfer/transfer_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/cli/cli/v2/test"
 	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) {
@@ -57,18 +58,49 @@ func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) {
 
 func TestNewCmdTransfer(t *testing.T) {
 	tests := []struct {
-		name    string
-		cli     string
-		wants   TransferOptions
-		wantErr string
+		name         string
+		cli          string
+		wants        TransferOptions
+		wantBaseRepo ghrepo.Interface
+		wantErr      bool
 	}{
 		{
-			name: "issue name",
-			cli:  "3252 OWNER/REPO",
+			name:    "no argument",
+			cli:     "",
+			wantErr: true,
+		},
+		{
+			name: "issue number argument",
+			cli:  "--repo cli/repo 23 OWNER/REPO",
+			wants: TransferOptions{
+				IssueNumber:      23,
+				DestRepoSelector: "OWNER/REPO",
+			},
+			wantBaseRepo: ghrepo.New("cli", "repo"),
+		},
+		{
+			name: "argument is hash prefixed number",
+			// Escaping is required here to avoid what I think is shellex treating it as a comment.
+			cli: "--repo cli/repo \\#23 OWNER/REPO",
+			wants: TransferOptions{
+				IssueNumber:      23,
+				DestRepoSelector: "OWNER/REPO",
+			},
+			wantBaseRepo: ghrepo.New("cli", "repo"),
+		},
+		{
+			name: "argument is a URL",
+			cli:  "https://github.com/cli/cli/issues/23 OWNER/REPO",
 			wants: TransferOptions{
-				IssueSelector:    "3252",
+				IssueNumber:      23,
 				DestRepoSelector: "OWNER/REPO",
 			},
+			wantBaseRepo: ghrepo.New("cli", "cli"),
+		},
+		{
+			name:    "argument cannot be parsed to an issue",
+			cli:     "unparseable OWNER/REPO",
+			wantErr: true,
 		},
 	}
 
@@ -84,15 +116,29 @@ func TestNewCmdTransfer(t *testing.T) {
 				gotOpts = opts
 				return nil
 			})
+			cmdutil.EnableRepoOverride(cmd, f)
+
 			cmd.SetArgs(argv)
 			cmd.SetIn(&bytes.Buffer{})
 			cmd.SetOut(&bytes.Buffer{})
 			cmd.SetErr(&bytes.Buffer{})
 
 			_, cErr := cmd.ExecuteC()
-			assert.NoError(t, cErr)
-			assert.Equal(t, tt.wants.IssueSelector, gotOpts.IssueSelector)
+			if tt.wantErr {
+				require.Error(t, cErr)
+				return
+			}
+
+			require.NoError(t, cErr)
+			assert.Equal(t, tt.wants.IssueNumber, gotOpts.IssueNumber)
 			assert.Equal(t, tt.wants.DestRepoSelector, gotOpts.DestRepoSelector)
+			actualBaseRepo, err := gotOpts.BaseRepo()
+			require.NoError(t, err)
+			assert.True(
+				t,
+				ghrepo.IsSame(tt.wantBaseRepo, actualBaseRepo),
+				"expected base repo %+v, got %+v", tt.wantBaseRepo, actualBaseRepo,
+			)
 		})
 	}
 }
diff --git a/pkg/cmd/issue/unpin/unpin_test.go b/pkg/cmd/issue/unpin/unpin_test.go
index 70a018d94..3cdf29a74 100644
--- a/pkg/cmd/issue/unpin/unpin_test.go
+++ b/pkg/cmd/issue/unpin/unpin_test.go
@@ -1,80 +1,21 @@
 package unpin
 
 import (
-	"bytes"
 	"net/http"
 	"testing"
 
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
-	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/google/shlex"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestNewCmdPin(t *testing.T) {
-	tests := []struct {
-		name    string
-		input   string
-		output  UnpinOptions
-		wantErr bool
-		errMsg  string
-	}{
-		{
-			name:    "no argument",
-			input:   "",
-			wantErr: true,
-			errMsg:  "accepts 1 arg(s), received 0",
-		},
-		{
-			name:  "issue number",
-			input: "6",
-			output: UnpinOptions{
-				SelectorArg: "6",
-			},
-		},
-		{
-			name:  "issue url",
-			input: "https://github.com/cli/cli/6",
-			output: UnpinOptions{
-				SelectorArg: "https://github.com/cli/cli/6",
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			ios, _, _, _ := iostreams.Test()
-			ios.SetStdinTTY(true)
-			ios.SetStdoutTTY(true)
-			f := &cmdutil.Factory{
-				IOStreams: ios,
-			}
-			argv, err := shlex.Split(tt.input)
-			assert.NoError(t, err)
-			var gotOpts *UnpinOptions
-			cmd := NewCmdUnpin(f, func(opts *UnpinOptions) error {
-				gotOpts = opts
-				return nil
-			})
-			cmd.SetArgs(argv)
-			cmd.SetIn(&bytes.Buffer{})
-			cmd.SetOut(&bytes.Buffer{})
-			cmd.SetErr(&bytes.Buffer{})
-
-			_, err = cmd.ExecuteC()
-			if tt.wantErr {
-				assert.Error(t, err)
-				assert.Equal(t, tt.errMsg, err.Error())
-				return
-			}
-
-			assert.NoError(t, err)
-			assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
-		})
-	}
+func TestNewCmdUnpin(t *testing.T) {
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdUnpin)
 }
 
 func TestUnpinRun(t *testing.T) {
@@ -89,7 +30,7 @@ func TestUnpinRun(t *testing.T) {
 		{
 			name: "unpin issue",
 			tty:  true,
-			opts: &UnpinOptions{SelectorArg: "20"},
+			opts: &UnpinOptions{IssueNumber: 20},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
@@ -113,7 +54,7 @@ func TestUnpinRun(t *testing.T) {
 		{
 			name: "issue not pinned",
 			tty:  true,
-			opts: &UnpinOptions{SelectorArg: "20"},
+			opts: &UnpinOptions{IssueNumber: 20},
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.GraphQL(`query IssueByNumber\b`),
diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go
index e1798af9f..391a288fb 100644
--- a/pkg/cmd/issue/view/view_test.go
+++ b/pkg/cmd/issue/view/view_test.go
@@ -10,9 +10,11 @@ import (
 
 	"github.com/cli/cli/v2/internal/browser"
 	"github.com/cli/cli/v2/internal/config"
+	fd "github.com/cli/cli/v2/internal/featuredetection"
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/run"
+	"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -47,6 +49,11 @@ func TestJSONFields(t *testing.T) {
 	})
 }
 
+func TestNewCmdView(t *testing.T) {
+	// Test shared parsing of issue number / URL.
+	argparsetest.TestArgParsing(t, NewCmdView)
+}
+
 func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
 	ios, _, stdout, stderr := iostreams.Test()
 	ios.SetStdoutTTY(isTTY)
@@ -116,7 +123,7 @@ func TestIssueView_web(t *testing.T) {
 			return ghrepo.New("OWNER", "REPO"), nil
 		},
 		WebMode:     true,
-		SelectorArg: "123",
+		IssueNumber: 123,
 	})
 	if err != nil {
 		t.Errorf("error running command `issue view`: %v", err)
@@ -273,7 +280,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
 				BaseRepo: func() (ghrepo.Interface, error) {
 					return ghrepo.New("OWNER", "REPO"), nil
 				},
-				SelectorArg: "123",
+				IssueNumber: 123,
 			}
 
 			err := viewRun(&opts)
@@ -490,3 +497,66 @@ func TestIssueView_nontty_Comments(t *testing.T) {
 		})
 	}
 }
+
+// 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, ""),
+		)
+
+		_, cmdTeardown := run.Stub()
+		defer cmdTeardown(t)
+
+		// Ignore the error because we have no way to really stub it without
+		// fully stubbing a GQL error structure in the request body.
+		_ = viewRun(&ViewOptions{
+			IO: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+			BaseRepo: func() (ghrepo.Interface, error) {
+				return ghrepo.New("OWNER", "REPO"), nil
+			},
+
+			Detector:    &fd.EnabledDetectorMock{},
+			IssueNumber: 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`))
+
+		_, cmdTeardown := run.Stub()
+		defer cmdTeardown(t)
+
+		// Ignore the error because we're not really interested in it.
+		_ = viewRun(&ViewOptions{
+			IO: ios,
+			HttpClient: func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			},
+			BaseRepo: func() (ghrepo.Interface, error) {
+				return ghrepo.New("OWNER", "REPO"), nil
+			},
+
+			Detector:    &fd.DisabledDetectorMock{},
+			IssueNumber: 123,
+		})
+
+		// Verify that our request contained projectCards
+		reg.Verify(t)
+	})
+}
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
index a6cc36abf..0941f2533 100644
--- a/pkg/cmd/pr/comment/comment_test.go
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -243,9 +243,10 @@ func Test_commentRun(t *testing.T) {
 		httpStubs     func(*testing.T, *httpmock.Registry)
 		stdout        string
 		stderr        string
+		wantsErr      bool
 	}{
 		{
-			name: "interactive editor",
+			name: "creating new comment with interactive editor succeeds",
 			input: &shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
@@ -260,7 +261,22 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 		{
-			name: "interactive editor with edit last",
+			name: "updating last comment with interactive editor fails if there are no comments and decline prompt to create",
+			input: &shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+				EditLast:    true,
+
+				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
+				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
+				ConfirmCreateIfNoneSurvey: func() (bool, error) { return false, nil },
+			},
+			emptyComments: true,
+			wantsErr:      true,
+		},
+		{
+			name: "updating last comment with interactive editor succeeds if there are comments",
 			input: &shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
@@ -273,10 +289,11 @@ func Test_commentRun(t *testing.T) {
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentUpdate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+			emptyComments: false,
+			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
 		},
 		{
-			name: "interactive editor with edit last and create if none",
+			name: "updating last comment with interactive editor creates new comment if there are no comments but --create-if-none",
 			input: &shared.CommentableOptions{
 				Interactive:  true,
 				InputType:    0,
@@ -289,12 +306,14 @@ func Test_commentRun(t *testing.T) {
 				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
-				mockCommentUpdate(t, reg)
+				mockCommentCreate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+			emptyComments: true,
+			stderr:        "No comments found. Creating a new comment.\n",
+			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive web",
+			name: "creating new comment with non-interactive web opens pull request in browser focusing on new comment",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeWeb,
@@ -302,10 +321,11 @@ func Test_commentRun(t *testing.T) {
 
 				OpenInBrowser: func(string) error { return nil },
 			},
-			stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+			emptyComments: true,
+			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
 		},
 		{
-			name: "non-interactive web with edit last",
+			name: "updating last comment with non-interactive web opens pull request in browser focusing on the last comment",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeWeb,
@@ -317,57 +337,49 @@ func Test_commentRun(t *testing.T) {
 					return nil
 				},
 			},
-			stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+			emptyComments: false,
+			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
 		},
 		{
-			name: "non-interactive web with edit last and create if none for empty comments",
+			name: "updating last comment with non-interactive web errors because there are no comments",
 			input: &shared.CommentableOptions{
-				Interactive:  false,
-				InputType:    shared.InputTypeWeb,
-				Body:         "",
-				EditLast:     true,
-				CreateIfNone: true,
-
-				OpenInBrowser: func(u string) error {
-					assert.Contains(t, u, "#issuecomment-new")
-					return nil
-				},
+				Interactive: false,
+				InputType:   shared.InputTypeWeb,
+				Body:        "",
+				EditLast:    true,
 			},
 			emptyComments: true,
-			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+			wantsErr:      true,
 		},
 		{
-			name: "non-interactive web with edit last and create if none",
+			name: "creating new comment with non-interactive editor succeeds",
 			input: &shared.CommentableOptions{
-				Interactive:  false,
-				InputType:    shared.InputTypeWeb,
-				Body:         "",
-				EditLast:     true,
-				CreateIfNone: true,
+				Interactive: false,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
 
-				OpenInBrowser: func(u string) error {
-					assert.Contains(t, u, "#issuecomment-111")
-					return nil
-				},
+				EditSurvey: func(string) (string, error) { return "comment body", nil },
 			},
-			stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive editor",
+			name: "updating last comment with non-interactive editor fails if there are no comments",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeEditor,
 				Body:        "",
+				EditLast:    true,
 
 				EditSurvey: func(string) (string, error) { return "comment body", nil },
 			},
-			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
-				mockCommentCreate(t, reg)
-			},
-			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+			emptyComments: true,
+			wantsErr:      true,
 		},
 		{
-			name: "non-interactive editor with edit last",
+			name: "updating last comment with non-interactive editor succeeds if there are comments",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeEditor,
@@ -382,7 +394,7 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
 		},
 		{
-			name: "non-interactive editor with edit last and create if none",
+			name: "updating last comment with non-interactive editor creates new comment if there are no comments but --create-if-none",
 			input: &shared.CommentableOptions{
 				Interactive:  false,
 				InputType:    shared.InputTypeEditor,
@@ -399,7 +411,7 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive inline",
+			name: "creating new comment with non-interactive inline succeeds if comment body is provided",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeInline,
@@ -411,7 +423,7 @@ func Test_commentRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 		{
-			name: "non-interactive inline with edit last",
+			name: "updating last comment with non-interactive inline succeeds if there are comments and comment body is provided",
 			input: &shared.CommentableOptions{
 				Interactive: false,
 				InputType:   shared.InputTypeInline,
@@ -421,7 +433,23 @@ func Test_commentRun(t *testing.T) {
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockCommentUpdate(t, reg)
 			},
-			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+			emptyComments: false,
+			stdout:        "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+		},
+		{
+			name: "updating last comment with non-interactive inline creates new comment if there are no comments but --create-if-none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeInline,
+				Body:         "comment body",
+				EditLast:     true,
+				CreateIfNone: true,
+			},
+			emptyComments: true,
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
 		},
 	}
 	for _, tt := range tests {
@@ -459,6 +487,10 @@ func Test_commentRun(t *testing.T) {
 
 		t.Run(tt.name, func(t *testing.T) {
 			err := shared.CommentableRun(tt.input)
+			if tt.wantsErr {
+				assert.Error(t, err)
+				return
+			}
 			assert.NoError(t, err)
 			assert.Equal(t, tt.stdout, stdout.String())
 			assert.Equal(t, tt.stderr, stderr.String())
diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go
index 4d7c294db..2a88b5eee 100644
--- a/pkg/cmd/pr/create/create_test.go
+++ b/pkg/cmd/pr/create/create_test.go
@@ -2,7 +2,6 @@ package create
 
 import (
 	"encoding/json"
-	"errors"
 	"fmt"
 	"net/http"
 	"os"
@@ -496,7 +495,7 @@ func Test_createRun(t *testing.T) {
 				`MaintainerCanModify: false`,
 				`Body:`,
 				``,
-				`  my body                                                                         `,
+				`  my body                                                                     `,
 				``,
 				``,
 			},
@@ -568,7 +567,7 @@ func Test_createRun(t *testing.T) {
 				`MaintainerCanModify: false`,
 				`Body:`,
 				``,
-				`  BODY                                                                            `,
+				`  BODY                                                                        `,
 				``,
 				``,
 			},
@@ -607,7 +606,7 @@ func Test_createRun(t *testing.T) {
 		`),
 		},
 		{
-			name: "survey",
+			name: "select a specific branch to push to on prompt",
 			tty:  true,
 			setup: func(opts *CreateOptions, t *testing.T) func() {
 				opts.TitleProvided = true
@@ -636,7 +635,9 @@ func Test_createRun(t *testing.T) {
 					}))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
 			},
 			promptStubs: func(pm *prompter.PrompterMock) {
@@ -651,6 +652,52 @@ func Test_createRun(t *testing.T) {
 			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
 			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
 		},
+		{
+			name: "skip pushing to branch on prompt",
+			tty:  true,
+			setup: func(opts *CreateOptions, t *testing.T) func() {
+				opts.TitleProvided = true
+				opts.BodyProvided = true
+				opts.Title = "my title"
+				opts.Body = "my body"
+				return func() {}
+			},
+			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
+				reg.StubRepoResponse("OWNER", "REPO")
+				reg.Register(
+					httpmock.GraphQL(`query UserCurrent\b`),
+					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
+				reg.Register(
+					httpmock.GraphQL(`mutation PullRequestCreate\b`),
+					httpmock.GraphQLMutation(`
+							{ "data": { "createPullRequest": { "pullRequest": {
+								"URL": "https://github.com/OWNER/REPO/pull/12"
+							} } } }`, func(input map[string]interface{}) {
+						assert.Equal(t, "REPOID", input["repositoryId"].(string))
+						assert.Equal(t, "my title", input["title"].(string))
+						assert.Equal(t, "my body", input["body"].(string))
+						assert.Equal(t, "master", input["baseRefName"].(string))
+						assert.Equal(t, "feature", input["headRefName"].(string))
+						assert.Equal(t, false, input["draft"].(bool))
+					}))
+			},
+			cmdStubs: func(cs *run.CommandStubber) {
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "")
+			},
+			promptStubs: func(pm *prompter.PrompterMock) {
+				pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
+					if p == "Where should we push the 'feature' branch?" {
+						return prompter.IndexFor(opts, "Skip pushing the branch")
+					} else {
+						return -1, prompter.NoSuchPromptErr(p)
+					}
+				}
+			},
+			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
+			expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
+		},
 		{
 			name: "project v2",
 			tty:  true,
@@ -699,7 +746,9 @@ func Test_createRun(t *testing.T) {
 					}))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
 			},
 			promptStubs: func(pm *prompter.PrompterMock) {
@@ -745,7 +794,9 @@ func Test_createRun(t *testing.T) {
 					}))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
 			},
 			promptStubs: func(pm *prompter.PrompterMock) {
@@ -794,7 +845,10 @@ func Test_createRun(t *testing.T) {
 					}))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 1, "")
+				cs.Register("git config remote.pushDefault", 1, "")
+				cs.Register("git config push.default", 1, "")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
 				cs.Register("git remote rename origin upstream", 0, "")
 				cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@@ -853,10 +907,10 @@ func Test_createRun(t *testing.T) {
 					}))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register("git show-ref --verify", 0, heredoc.Doc(`
-			deadbeef HEAD
-			deadb00f refs/remotes/upstream/feature
-			deadbeef refs/remotes/origin/feature`)) // determineTrackingBranch
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 0, heredoc.Doc(`
+				deadbeef HEAD
+				deadbeef refs/remotes/origin/feature`))
 			},
 			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
 			expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n",
@@ -889,11 +943,12 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(`
 			branch.feature.remote origin
 			branch.feature.merge refs/heads/my-feat2
-		`)) // determineTrackingBranch
-				cs.Register("git show-ref --verify", 0, heredoc.Doc(`
+		`))
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/my-feat2")
+				cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/my-feat2", 0, heredoc.Doc(`
 			deadbeef HEAD
 			deadbeef refs/remotes/origin/my-feat2
-		`)) // determineTrackingBranch
+		`))
 			},
 			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
 			expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n",
@@ -1073,8 +1128,10 @@ func Test_createRun(t *testing.T) {
 					httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
 				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
 			},
 			promptStubs: func(pm *prompter.PrompterMock) {
@@ -1105,8 +1162,10 @@ func Test_createRun(t *testing.T) {
 				mockRetrieveProjects(t, reg)
 			},
 			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
 				cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
+				cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
 				cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
 			},
 			promptStubs: func(pm *prompter.PrompterMock) {
@@ -1271,31 +1330,6 @@ func Test_createRun(t *testing.T) {
 			},
 			wantErr: "cannot open in browser: maximum URL length exceeded",
 		},
-		{
-			name: "no local git repo",
-			setup: func(opts *CreateOptions, t *testing.T) func() {
-				opts.Title = "My PR"
-				opts.TitleProvided = true
-				opts.Body = ""
-				opts.BodyProvided = true
-				opts.HeadBranch = "feature"
-				opts.RepoOverride = "OWNER/REPO"
-				opts.Remotes = func() (context.Remotes, error) {
-					return nil, errors.New("not a git repository")
-				}
-				return func() {}
-			},
-			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
-				reg.Register(
-					httpmock.GraphQL(`mutation PullRequestCreate\b`),
-					httpmock.StringResponse(`
-							{ "data": { "createPullRequest": { "pullRequest": {
-								"URL": "https://github.com/OWNER/REPO/pull/12"
-							} } } }
-						`))
-			},
-			expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
-		},
 		{
 			name: "single commit title and body are used",
 			tty:  true,
@@ -1520,19 +1554,45 @@ func Test_createRun(t *testing.T) {
 					branch.task1.remote origin
 					branch.task1.merge refs/heads/task1
 					branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig
-				cs.Register(`git show-ref --verify`, 0, heredoc.Doc(`
+				cs.Register("git rev-parse --symbolic-full-name task1@{push}", 0, "refs/remotes/origin/task1")
+				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/task1`, 0, heredoc.Doc(`
 					deadbeef HEAD
-					deadb00f refs/remotes/upstream/feature/feat2
-					deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch
+					deadbeef refs/remotes/origin/task1`))
 			},
 			expectedOut:    "https://github.com/OWNER/REPO/pull/12\n",
 			expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n",
 		},
+		{
+			name: "--head contains <user>:<branch> syntax",
+			httpStubs: func(reg *httpmock.Registry, t *testing.T) {
+				reg.Register(
+					httpmock.GraphQL(`mutation PullRequestCreate\b`),
+					httpmock.GraphQLMutation(`
+						{ "data": { "createPullRequest": { "pullRequest": {
+							"URL": "https://github.com/OWNER/REPO/pull/12"
+						} } } }`,
+						func(input map[string]interface{}) {
+							assert.Equal(t, "REPOID", input["repositoryId"])
+							assert.Equal(t, "my title", input["title"])
+							assert.Equal(t, "my body", input["body"])
+							assert.Equal(t, "master", input["baseRefName"])
+							assert.Equal(t, "otherowner:feature", input["headRefName"])
+						}))
+			},
+			setup: func(opts *CreateOptions, t *testing.T) func() {
+				opts.TitleProvided = true
+				opts.BodyProvided = true
+				opts.Title = "my title"
+				opts.Body = "my body"
+				opts.HeadBranch = "otherowner:feature"
+				return func() {}
+			},
+			expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			branch := "feature"
-
 			reg := &httpmock.Registry{}
 			reg.StubRepoInfoResponse("OWNER", "REPO", "master")
 			defer reg.Verify(t)
@@ -1548,7 +1608,7 @@ func Test_createRun(t *testing.T) {
 
 			cs, cmdTeardown := run.Stub()
 			defer cmdTeardown(t)
-			cs.Register(`git status --porcelain`, 0, "")
+
 			if !tt.customBranchConfig {
 				cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
 			}
@@ -1599,6 +1659,10 @@ func Test_createRun(t *testing.T) {
 			}
 			defer cleanSetup()
 
+			if opts.HeadBranch == "" {
+				cs.Register(`git status --porcelain`, 0, "")
+			}
+
 			err := createRun(&opts)
 			output := &test.CmdOut{
 				OutBuf:     stdout,
@@ -1622,109 +1686,166 @@ func Test_createRun(t *testing.T) {
 	}
 }
 
-func Test_tryDetermineTrackingRef(t *testing.T) {
-	tests := []struct {
-		name                string
-		cmdStubs            func(*run.CommandStubber)
-		headBranchConfig    git.BranchConfig
-		remotes             context.Remotes
-		expectedTrackingRef trackingRef
-		expectedFound       bool
-	}{
-		{
-			name: "empty",
-			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD")
-			},
-			headBranchConfig:    git.BranchConfig{},
-			expectedTrackingRef: trackingRef{},
-			expectedFound:       false,
+func TestRemoteGuessing(t *testing.T) {
+	// Given git config does not provide the necessary info to determine a remote
+	cs, cmdTeardown := run.Stub()
+	defer cmdTeardown(t)
+
+	cs.Register(`git status --porcelain`, 0, "")
+	cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+	cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "")
+	cs.Register("git config remote.pushDefault", 1, "")
+	cs.Register("git config push.default", 1, "")
+
+	// And Given there is a remote on a SHA that matches the current HEAD
+	cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature`, 0, heredoc.Doc(`
+	deadbeef HEAD
+	deadb00f refs/remotes/upstream/feature
+	deadbeef refs/remotes/origin/feature`))
+
+	// When the command is run
+	reg := &httpmock.Registry{}
+	reg.StubRepoInfoResponse("OWNER", "REPO", "master")
+	defer reg.Verify(t)
+
+	reg.Register(
+		httpmock.GraphQL(`mutation PullRequestCreate\b`),
+		httpmock.GraphQLMutation(`
+				{ "data": { "createPullRequest": { "pullRequest": {
+					"URL": "https://github.com/OWNER/REPO/pull/12"
+				} } } }`, func(input map[string]interface{}) {
+			assert.Equal(t, "REPOID", input["repositoryId"].(string))
+			assert.Equal(t, "master", input["baseRefName"].(string))
+			assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string))
+		}))
+
+	ios, _, _, _ := iostreams.Test()
+
+	opts := CreateOptions{
+		HttpClient: func() (*http.Client, error) {
+			return &http.Client{Transport: reg}, nil
 		},
-		{
-			name: "no match",
-			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register("git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature", 0, "abc HEAD\nbca refs/remotes/upstream/feature")
-			},
-			headBranchConfig: git.BranchConfig{},
-			remotes: context.Remotes{
-				&context.Remote{
-					Remote: &git.Remote{Name: "upstream"},
-					Repo:   ghrepo.New("octocat", "Spoon-Knife"),
-				},
-				&context.Remote{
-					Remote: &git.Remote{Name: "origin"},
-					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
-				},
-			},
-			expectedTrackingRef: trackingRef{},
-			expectedFound:       false,
+		Config: func() (gh.Config, error) {
+			return config.NewBlankConfig(), nil
 		},
-		{
-			name: "match",
-			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature$`, 0, heredoc.Doc(`
-		deadbeef HEAD
-		deadb00f refs/remotes/upstream/feature
-		deadbeef refs/remotes/origin/feature
-	`))
-			},
-			headBranchConfig: git.BranchConfig{},
-			remotes: context.Remotes{
-				&context.Remote{
-					Remote: &git.Remote{Name: "upstream"},
-					Repo:   ghrepo.New("octocat", "Spoon-Knife"),
+		Browser:  &browser.Stub{},
+		IO:       ios,
+		Prompter: &prompter.PrompterMock{},
+		GitClient: &git.Client{
+			GhPath:  "some/path/gh",
+			GitPath: "some/path/git",
+		},
+		Finder: shared.NewMockFinder("feature", nil, nil),
+		Remotes: func() (context.Remotes, error) {
+			return context.Remotes{
+				{
+					Remote: &git.Remote{
+						Name:     "upstream",
+						Resolved: "base",
+					},
+					Repo: ghrepo.New("OWNER", "REPO"),
 				},
-				&context.Remote{
-					Remote: &git.Remote{Name: "origin"},
-					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
+				{
+					Remote: &git.Remote{
+						Name: "origin",
+					},
+					Repo: ghrepo.New("OTHEROWNER", "REPO-FORK"),
 				},
-			},
-			expectedTrackingRef: trackingRef{
-				remoteName: "origin",
-				branchName: "feature",
-			},
-			expectedFound: true,
+			}, nil
 		},
-		{
-			name: "respect tracking config",
-			cmdStubs: func(cs *run.CommandStubber) {
-				cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(`
-		deadbeef HEAD
-		deadb00f refs/remotes/origin/feature
-	`))
-			},
-			headBranchConfig: git.BranchConfig{
-				RemoteName: "origin",
-				MergeRef:   "refs/heads/great-feat",
-			},
-			remotes: context.Remotes{
-				&context.Remote{
-					Remote: &git.Remote{Name: "origin"},
-					Repo:   ghrepo.New("hubot", "Spoon-Knife"),
-				},
-			},
-			expectedTrackingRef: trackingRef{},
-			expectedFound:       false,
+		Branch: func() (string, error) {
+			return "feature", nil
 		},
+
+		TitleProvided: true,
+		BodyProvided:  true,
+		Title:         "my title",
+		Body:          "my body",
 	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			cs, cmdTeardown := run.Stub()
-			defer cmdTeardown(t)
 
-			tt.cmdStubs(cs)
+	require.NoError(t, createRun(&opts))
 
-			gitClient := &git.Client{
-				GhPath:  "some/path/gh",
-				GitPath: "some/path/git",
-			}
+	// Then guessed remote is used for the PR head,
+	// which annoyingly, is asserted above on the line:
+	// assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string))
+	//
+	// This is because OTHEROWNER relates to the "origin" remote, which has a
+	// SHA that matches the HEAD ref in the `git show-ref` output.
+}
 
-			ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", tt.headBranchConfig)
+func TestNoRepoCanBeDetermined(t *testing.T) {
+	// Given no head repo can be determined from git config
+	cs, cmdTeardown := run.Stub()
+	defer cmdTeardown(t)
 
-			assert.Equal(t, tt.expectedTrackingRef, ref)
-			assert.Equal(t, tt.expectedFound, found)
-		})
+	cs.Register(`git status --porcelain`, 0, "")
+	cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
+	cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "")
+	cs.Register("git config remote.pushDefault", 1, "")
+	cs.Register("git config push.default", 1, "")
+
+	// And Given there is no remote on the correct SHA
+	cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, heredoc.Doc(`
+	deadbeef HEAD
+	deadb00f refs/remotes/origin/feature`))
+
+	// When the command is run with no TTY
+	reg := &httpmock.Registry{}
+	reg.StubRepoInfoResponse("OWNER", "REPO", "master")
+	defer reg.Verify(t)
+
+	ios, _, _, stderr := iostreams.Test()
+
+	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: &prompter.PrompterMock{},
+		GitClient: &git.Client{
+			GhPath:  "some/path/gh",
+			GitPath: "some/path/git",
+		},
+		Finder: shared.NewMockFinder("feature", nil, nil),
+		Remotes: func() (context.Remotes, error) {
+			return context.Remotes{
+				{
+					Remote: &git.Remote{
+						Name:     "origin",
+						Resolved: "base",
+					},
+					Repo: ghrepo.New("OWNER", "REPO"),
+				},
+			}, nil
+		},
+		Branch: func() (string, error) {
+			return "feature", nil
+		},
+
+		TitleProvided: true,
+		BodyProvided:  true,
+		Title:         "my title",
+		Body:          "my body",
+	}
+
+	// When we run the command
+	err := createRun(&opts)
+
+	// Then create fails
+	require.Equal(t, cmdutil.SilentError, err)
+	assert.Equal(t, "aborted: you must first push the current branch to a remote, or use the --head flag\n", stderr.String())
+}
+
+func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef {
+	parsed, err := shared.ParseQualifiedHeadRef(ref)
+	if err != nil {
+		panic(err)
 	}
+	return parsed
 }
 
 func Test_generateCompareURL(t *testing.T) {
@@ -1738,9 +1859,13 @@ func Test_generateCompareURL(t *testing.T) {
 		{
 			name: "basic",
 			ctx: CreateContext{
-				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
-				BaseBranch:      "main",
-				HeadBranchLabel: "feature",
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
+						baseBranchName: "main",
+					},
+				},
 			},
 			want:    "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1",
 			wantErr: false,
@@ -1748,9 +1873,13 @@ func Test_generateCompareURL(t *testing.T) {
 		{
 			name: "with labels",
 			ctx: CreateContext{
-				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
-				BaseBranch:      "a",
-				HeadBranchLabel: "b",
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("b"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
+						baseBranchName: "a",
+					},
+				},
 			},
 			state: shared.IssueMetadataState{
 				Labels: []string{"one", "two three"},
@@ -1761,35 +1890,47 @@ func Test_generateCompareURL(t *testing.T) {
 		{
 			name: "'/'s in branch names/labels are percent-encoded",
 			ctx: CreateContext{
-				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
-				BaseBranch:      "main/trunk",
-				HeadBranchLabel: "owner:feature",
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:feature"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"),
+						baseBranchName: "main/trunk",
+					},
+				},
 			},
-			want:    "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:feature?body=&expand=1",
+			want:    "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1",
 			wantErr: false,
 		},
 		{
 			name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ",
 			/*
-					- Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway
-					- !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[]
-					- : is GitHub separator between a fork name and a branch name
-				    - See https://github.com/golang/go/issues/27559.
+				- Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway
+				- !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[]
+				- : is GitHub separator between a fork name and a branch name
+				- See https://github.com/golang/go/issues/27559.
 			*/
 			ctx: CreateContext{
-				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
-				BaseBranch:      "main/trunk",
-				HeadBranchLabel: "owner:!$&'()+,;=@",
+				PRRefs: &skipPushRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:!$&'()+,;=@"),
+					baseRefs: baseRefs{
+						baseRepo:       api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"),
+						baseBranchName: "main/trunk",
+					},
+				},
 			},
-			want:    "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
+			want:    "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
 			wantErr: false,
 		},
 		{
 			name: "with template",
 			ctx: CreateContext{
-				BaseRepo:        api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
-				BaseBranch:      "main",
-				HeadBranchLabel: "feature",
+				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{
 				Template: "story.md",
diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go
index 611f71894..f9e00c3b8 100644
--- a/pkg/cmd/pr/review/review_test.go
+++ b/pkg/cmd/pr/review/review_test.go
@@ -283,7 +283,7 @@ func TestPRReview_interactive(t *testing.T) {
 	assert.Equal(t, heredoc.Doc(`
 		Got:
 
-		  cool story                                                                      
+		  cool story                                                                  
 
 	`), output.String())
 	assert.Equal(t, "✓ Approved pull request OWNER/REPO#123\n", output.Stderr())
diff --git a/pkg/cmd/pr/shared/find_refs_resolution_test.go b/pkg/cmd/pr/shared/find_refs_resolution_test.go
new file mode 100644
index 000000000..d2393bf10
--- /dev/null
+++ b/pkg/cmd/pr/shared/find_refs_resolution_test.go
@@ -0,0 +1,510 @@
+package shared
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	ghContext "github.com/cli/cli/v2/context"
+	"github.com/cli/cli/v2/git"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	o "github.com/cli/cli/v2/pkg/option"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestQualifiedHeadRef(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		behavior           string
+		ref                string
+		expectedString     string
+		expectedBranchName string
+		expectedError      error
+	}{
+		{
+			behavior:           "when a branch is provided, the parsed qualified head ref only has a branch",
+			ref:                "feature-branch",
+			expectedString:     "feature-branch",
+			expectedBranchName: "feature-branch",
+		},
+		{
+			behavior:           "when an owner and branch are provided, the parsed qualified head ref has both",
+			ref:                "owner:feature-branch",
+			expectedString:     "owner:feature-branch",
+			expectedBranchName: "feature-branch",
+		},
+		{
+			behavior:      "when the structure cannot be interpreted correctly, an error is returned",
+			ref:           "owner:feature-branch:extra",
+			expectedError: errors.New("invalid qualified head ref format 'owner:feature-branch:extra'"),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.behavior, func(t *testing.T) {
+			t.Parallel()
+
+			qualifiedHeadRef, err := ParseQualifiedHeadRef(tc.ref)
+			if tc.expectedError != nil {
+				require.Equal(t, tc.expectedError, err)
+				return
+			}
+
+			require.NoError(t, err)
+			assert.Equal(t, tc.expectedString, qualifiedHeadRef.String())
+			assert.Equal(t, tc.expectedBranchName, qualifiedHeadRef.BranchName())
+		})
+	}
+}
+
+func TestPRFindRefs(t *testing.T) {
+	t.Parallel()
+
+	t.Run("qualified head ref with owner", func(t *testing.T) {
+		t.Parallel()
+
+		refs := PRFindRefs{
+			qualifiedHeadRef: mustParseQualifiedHeadRef("forkowner:feature-branch"),
+		}
+
+		require.Equal(t, "forkowner:feature-branch", refs.QualifiedHeadRef())
+		require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef())
+	})
+
+	t.Run("qualified head ref without owner", func(t *testing.T) {
+		t.Parallel()
+
+		refs := PRFindRefs{
+			qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
+		}
+
+		require.Equal(t, "feature-branch", refs.QualifiedHeadRef())
+		require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef())
+	})
+
+	t.Run("base repo", func(t *testing.T) {
+		t.Parallel()
+
+		refs := PRFindRefs{
+			baseRepo: ghrepo.New("owner", "repo"),
+		}
+
+		require.True(t, ghrepo.IsSame(refs.BaseRepo(), ghrepo.New("owner", "repo")), "expected repos to be the same")
+	})
+
+	t.Run("matches", func(t *testing.T) {
+		t.Parallel()
+
+		testCases := []struct {
+			behavior         string
+			refs             PRFindRefs
+			baseBranchName   string
+			qualifiedHeadRef string
+			expectedMatch    bool
+		}{
+			{
+				behavior: "when qualified head refs don't match, returns false",
+				refs: PRFindRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("owner:feature-branch"),
+				},
+				baseBranchName:   "feature-branch",
+				qualifiedHeadRef: "feature-branch",
+				expectedMatch:    false,
+			},
+			{
+				behavior: "when base branches don't match, returns false",
+				refs: PRFindRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
+					baseBranchName:   o.Some("not-main"),
+				},
+				baseBranchName:   "main",
+				qualifiedHeadRef: "feature-branch",
+				expectedMatch:    false,
+			},
+			{
+				behavior: "when head refs match and there is no base branch, returns true",
+				refs: PRFindRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
+					baseBranchName:   o.None[string](),
+				},
+				baseBranchName:   "main",
+				qualifiedHeadRef: "feature-branch",
+				expectedMatch:    true,
+			},
+			{
+				behavior: "when head refs match and base branches match, returns true",
+				refs: PRFindRefs{
+					qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
+					baseBranchName:   o.Some("main"),
+				},
+				baseBranchName:   "main",
+				qualifiedHeadRef: "feature-branch",
+				expectedMatch:    true,
+			},
+		}
+
+		for _, tc := range testCases {
+			t.Run(tc.behavior, func(t *testing.T) {
+				t.Parallel()
+
+				require.Equal(t, tc.expectedMatch, tc.refs.Matches(tc.baseBranchName, tc.qualifiedHeadRef))
+			})
+		}
+	})
+}
+
+func TestPullRequestResolution(t *testing.T) {
+	t.Parallel()
+
+	baseRepo := ghrepo.New("owner", "repo")
+	baseRemote := ghContext.Remote{
+		Remote: &git.Remote{
+			Name: "upstream",
+		},
+		Repo: ghrepo.New("owner", "repo"),
+	}
+
+	forkRemote := ghContext.Remote{
+		Remote: &git.Remote{
+			Name: "origin",
+		},
+		Repo: ghrepo.New("otherowner", "repo-fork"),
+	}
+
+	t.Run("when the base repo is nil, returns an error", func(t *testing.T) {
+		t.Parallel()
+
+		resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn)
+		_, err := resolver.ResolvePullRequestRefs(nil, "", "")
+		require.Error(t, err)
+	})
+
+	t.Run("when the local branch name is empty, returns an error", func(t *testing.T) {
+		t.Parallel()
+
+		resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn)
+		_, err := resolver.ResolvePullRequestRefs(baseRepo, "", "")
+		require.Error(t, err)
+	})
+
+	t.Run("when the default pr head has a repo, it is used for the refs", func(t *testing.T) {
+		t.Parallel()
+
+		// Push revision is the first thing checked for resolution,
+		// so nothing else needs to be stubbed.
+		repoResolvedFromPushRevisionClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{
+				Remote: "origin",
+				Branch: "feature-branch",
+			}, nil),
+		}
+
+		resolver := NewPullRequestFindRefsResolver(
+			repoResolvedFromPushRevisionClient,
+			stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+		)
+
+		refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch")
+		require.NoError(t, err)
+
+		expectedRefs := PRFindRefs{
+			qualifiedHeadRef: QualifiedHeadRef{
+				owner:      o.Some("otherowner"),
+				branchName: "feature-branch",
+			},
+			baseRepo:       baseRepo,
+			baseBranchName: o.Some("main"),
+		}
+
+		require.Equal(t, expectedRefs, refs)
+	})
+
+	t.Run("when the default pr head does not have a repo, we use the base repo for the head", func(t *testing.T) {
+		t.Parallel()
+
+		// All the values stubbed here result in being unable to resolve a default repo.
+		noRepoResolutionStubClient := stubGitConfigClient{
+			pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
+			readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+			pushDefaultFn:       stubPushDefault("", nil),
+			remotePushDefaultFn: stubRemotePushDefault("", nil),
+		}
+
+		resolver := NewPullRequestFindRefsResolver(
+			noRepoResolutionStubClient,
+			stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+		)
+
+		refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch")
+		require.NoError(t, err)
+
+		expectedRefs := PRFindRefs{
+			qualifiedHeadRef: QualifiedHeadRef{
+				owner:      o.None[string](),
+				branchName: "feature-branch",
+			},
+			baseRepo:       baseRepo,
+			baseBranchName: o.Some("main"),
+		}
+		require.Equal(t, expectedRefs, refs)
+	})
+}
+
+func TestTryDetermineDefaultPRHead(t *testing.T) {
+	t.Parallel()
+
+	baseRepo := ghrepo.New("owner", "repo")
+	baseRemote := ghContext.Remote{
+		Remote: &git.Remote{
+			Name: "upstream",
+		},
+		Repo: baseRepo,
+	}
+
+	forkRepo := ghrepo.New("otherowner", "repo-fork")
+	forkRemote := ghContext.Remote{
+		Remote: &git.Remote{
+			Name: "origin",
+		},
+		Repo: forkRepo,
+	}
+	forkRepoURL, err := url.Parse("https://github.com/otherowner/repo-fork.git")
+	require.NoError(t, err)
+
+	t.Run("when the push revision is set, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRevisionClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{
+				Remote: "origin",
+				Branch: "remote-feature-branch",
+			}, nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRevisionClient,
+			stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "remote-feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when the branch config push remote is set to a name, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRemoteClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
+			readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+				PushRemoteName: "origin",
+			}, nil),
+			pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRemoteClient,
+			stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when the branch config push remote is set to a URL, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRemoteClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
+			readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+				PushRemoteURL: forkRepoURL,
+			}, nil),
+			pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRemoteClient,
+			dummyRemoteToRepoResolver(),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when a remote push default is set, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRemoteClient := stubGitConfigClient{
+			pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
+			readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+			pushDefaultFn:       stubPushDefault(git.PushDefaultCurrent, nil),
+			remotePushDefaultFn: stubRemotePushDefault("origin", nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRemoteClient,
+			stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when the branch config remote is set to a name, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRemoteClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
+			readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+				RemoteName: "origin",
+			}, nil),
+			pushDefaultFn:       stubPushDefault(git.PushDefaultCurrent, nil),
+			remotePushDefaultFn: stubRemotePushDefault("", nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRemoteClient,
+			stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when the branch config remote is set to a URL, use that", func(t *testing.T) {
+		t.Parallel()
+
+		repoResolvedFromPushRemoteClient := stubGitConfigClient{
+			pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
+			readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+				RemoteURL: forkRepoURL,
+			}, nil),
+			pushDefaultFn:       stubPushDefault(git.PushDefaultCurrent, nil),
+			remotePushDefaultFn: stubRemotePushDefault("", nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			repoResolvedFromPushRemoteClient,
+			dummyRemoteToRepoResolver(),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when git didn't provide the necessary information, return none for the remote", func(t *testing.T) {
+		t.Parallel()
+
+		// All the values stubbed here result in being unable to resolve a default repo.
+		noRepoResolutionStubClient := stubGitConfigClient{
+			pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
+			readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+			pushDefaultFn:       stubPushDefault("", nil),
+			remotePushDefaultFn: stubRemotePushDefault("", nil),
+		}
+
+		defaultPRHead, err := TryDetermineDefaultPRHead(
+			noRepoResolutionStubClient,
+			stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+			"feature-branch",
+		)
+		require.NoError(t, err)
+
+		require.True(t, defaultPRHead.Repo.IsNone(), "expected repo to be none")
+		require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+	})
+
+	t.Run("when the push default is tracking or upstream, use the merge ref", func(t *testing.T) {
+		t.Parallel()
+
+		testCases := []struct {
+			pushDefault git.PushDefault
+		}{
+			{pushDefault: git.PushDefaultTracking},
+			{pushDefault: git.PushDefaultUpstream},
+		}
+
+		for _, tc := range testCases {
+			t.Run(string(tc.pushDefault), func(t *testing.T) {
+				t.Parallel()
+
+				repoResolvedFromPushRemoteClient := stubGitConfigClient{
+					pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						PushRemoteName: "origin",
+						MergeRef:       "main",
+					}, nil),
+					pushDefaultFn: stubPushDefault(tc.pushDefault, nil),
+				}
+
+				defaultPRHead, err := TryDetermineDefaultPRHead(
+					repoResolvedFromPushRemoteClient,
+					stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+					"feature-branch",
+				)
+				require.NoError(t, err)
+
+				require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
+				require.Equal(t, "main", defaultPRHead.BranchName)
+			})
+		}
+
+		t.Run("but if the merge ref is empty, use the provided branch name", func(t *testing.T) {
+			t.Parallel()
+
+			repoResolvedFromPushRemoteClient := stubGitConfigClient{
+				pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
+				readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+					PushRemoteName: "origin",
+					MergeRef:       "", // intentionally empty
+				}, nil),
+				pushDefaultFn: stubPushDefault(git.PushDefaultUpstream, nil),
+			}
+
+			defaultPRHead, err := TryDetermineDefaultPRHead(
+				repoResolvedFromPushRemoteClient,
+				stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
+				"feature-branch",
+			)
+			require.NoError(t, err)
+
+			require.Equal(t, "feature-branch", defaultPRHead.BranchName)
+		})
+	})
+
+}
+
+func dummyRemotesFn() (ghContext.Remotes, error) {
+	panic("remotes fn not implemented")
+}
+
+func dummyRemoteToRepoResolver() remoteToRepoResolver {
+	return NewRemoteToRepoResolver(dummyRemotesFn)
+}
+
+func stubRemoteToRepoResolver(remotes ghContext.Remotes, err error) remoteToRepoResolver {
+	return NewRemoteToRepoResolver(func() (ghContext.Remotes, error) {
+		return remotes, err
+	})
+}
+
+func mustParseQualifiedHeadRef(ref string) QualifiedHeadRef {
+	parsed, err := ParseQualifiedHeadRef(ref)
+	if err != nil {
+		panic(err)
+	}
+	return parsed
+}
diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go
index 36551ab42..e1aae16b1 100644
--- a/pkg/cmd/pr/shared/finder_test.go
+++ b/pkg/cmd/pr/shared/finder_test.go
@@ -1,46 +1,41 @@
 package shared
 
 import (
+	"context"
 	"errors"
-	"fmt"
 	"net/http"
 	"net/url"
 	"testing"
 
-	"github.com/cli/cli/v2/context"
+	ghContext "github.com/cli/cli/v2/context"
 	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/httpmock"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
 type args struct {
-	baseRepoFn        func() (ghrepo.Interface, error)
-	branchFn          func() (string, error)
-	branchConfig      func(string) (git.BranchConfig, error)
-	pushDefault       func() (string, error)
-	remotePushDefault func() (string, error)
-	parsePushRevision func(string) (string, error)
-	selector          string
-	fields            []string
-	baseBranch        string
+	baseRepoFn      func() (ghrepo.Interface, error)
+	branchFn        func() (string, error)
+	gitConfigClient stubGitConfigClient
+	selector        string
+	fields          []string
+	baseBranch      string
 }
 
 func TestFind(t *testing.T) {
-	// TODO: Abstract these out meaningfully for reuse in parsePRRefs tests
 	originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git")
 	if err != nil {
 		t.Fatal(err)
 	}
-	remoteOrigin := context.Remote{
+	remoteOrigin := ghContext.Remote{
 		Remote: &git.Remote{
 			Name:     "origin",
 			FetchURL: originOwnerUrl,
 		},
 		Repo: ghrepo.New("ORIGINOWNER", "REPO"),
 	}
-	remoteOther := context.Remote{
+	remoteOther := ghContext.Remote{
 		Remote: &git.Remote{
 			Name:     "other",
 			FetchURL: originOwnerUrl,
@@ -52,7 +47,7 @@ func TestFind(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	remoteUpstream := context.Remote{
+	remoteUpstream := ghContext.Remote{
 		Remote: &git.Remote{
 			Name:     "upstream",
 			FetchURL: upstreamOwnerUrl,
@@ -77,7 +72,6 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -99,12 +93,14 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{
-					PushRemoteName: remoteOrigin.Remote.Name,
-				}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						PushRemoteName: remoteOrigin.Remote.Name,
+					}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -134,9 +130,11 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			wantErr: true,
 		},
@@ -157,9 +155,11 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			httpStub: nil,
 			wantPR:   13,
@@ -174,9 +174,11 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -197,9 +199,11 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -223,15 +227,17 @@ func TestFind(t *testing.T) {
 						ExitCode: 128,
 					}
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{}, &git.GitError{
-					Stderr:   "fatal: branchConfig error",
-					ExitCode: 128,
-				}),
-				pushDefault: stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", &git.GitError{
-					Stderr:   "fatal: remotePushDefault error",
-					ExitCode: 128,
-				}),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, &git.GitError{
+						Stderr:   "fatal: branchConfig error",
+						ExitCode: 128,
+					}),
+					pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", &git.GitError{
+						Stderr:   "fatal: remotePushDefault error",
+						ExitCode: 128,
+					}),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -252,10 +258,12 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -296,10 +304,12 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -339,10 +349,12 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -374,10 +386,12 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -423,13 +437,15 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{
-					MergeRef:       "refs/heads/blue-upstream-berries",
-					PushRemoteName: "upstream",
-				}, nil),
-				pushDefault:       stubPushDefault("upstream", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						MergeRef:       "refs/heads/blue-upstream-berries",
+						PushRemoteName: "upstream",
+					}, nil),
+					pushDefaultFn:       stubPushDefault("upstream", nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -463,13 +479,15 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{
-					MergeRef:      "refs/heads/blue-upstream-berries",
-					PushRemoteURL: remoteUpstream.Remote.FetchURL,
-				}, nil),
-				pushDefault:       stubPushDefault("upstream", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						MergeRef:      "refs/heads/blue-upstream-berries",
+						PushRemoteURL: remoteUpstream.Remote.FetchURL,
+					}, nil),
+					pushDefaultFn:       stubPushDefault("upstream", nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -499,10 +517,12 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig:      stubBranchConfig(git.BranchConfig{}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
-				parsePushRevision: stubParsedPushRevision("other/blueberries", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn:  stubBranchConfig(git.BranchConfig{}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+					pushRevisionFn:      stubPushRevision(git.RemoteTrackingRef{Remote: "other", Branch: "blueberries"}, nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -534,9 +554,11 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{
-					MergeRef: "refs/pull/13/head",
-				}, nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						MergeRef: "refs/pull/13/head",
+					}, nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -559,11 +581,13 @@ func TestFind(t *testing.T) {
 				branchFn: func() (string, error) {
 					return "blueberries", nil
 				},
-				branchConfig: stubBranchConfig(git.BranchConfig{
-					MergeRef: "refs/pull/13/head",
-				}, nil),
-				pushDefault:       stubPushDefault("simple", nil),
-				remotePushDefault: stubRemotePushDefault("", nil),
+				gitConfigClient: stubGitConfigClient{
+					readBranchConfigFn: stubBranchConfig(git.BranchConfig{
+						MergeRef: "refs/pull/13/head",
+					}, nil),
+					pushDefaultFn:       stubPushDefault(git.PushDefaultSimple, nil),
+					remotePushDefaultFn: stubRemotePushDefault("", nil),
+				},
 			},
 			httpStub: func(r *httpmock.Registry) {
 				r.Register(
@@ -575,32 +599,32 @@ func TestFind(t *testing.T) {
 				r.Register(
 					httpmock.GraphQL(`query PullRequestProjectItems\b`),
 					httpmock.GraphQLQuery(`{
-                        "data": {
-                          "repository": {
-                            "pullRequest": {
-                              "projectItems": {
-                                "nodes": [
-                                  {
-                                    "id": "PVTI_lADOB-vozM4AVk16zgK6U50",
-                                    "project": {
-                                      "id": "PVT_kwDOB-vozM4AVk16",
-                                      "title": "Test Project"
-                                    },
-                                    "status": {
-                                      "optionId": "47fc9ee4",
-                                      "name": "In Progress"
-                                    }
-                                  }
-                                ],
-                                "pageInfo": {
-                                  "hasNextPage": false,
-                                  "endCursor": "MQ"
-                                }
-                              }
-                            }
-                          }
-                        }
-                      }`,
+		                "data": {
+		                  "repository": {
+		                    "pullRequest": {
+		                      "projectItems": {
+		                        "nodes": [
+		                          {
+		                            "id": "PVTI_lADOB-vozM4AVk16zgK6U50",
+		                            "project": {
+		                              "id": "PVT_kwDOB-vozM4AVk16",
+		                              "title": "Test Project"
+		                            },
+		                            "status": {
+		                              "optionId": "47fc9ee4",
+		                              "name": "In Progress"
+		                            }
+		                          }
+		                        ],
+		                        "pageInfo": {
+		                          "hasNextPage": false,
+		                          "endCursor": "MQ"
+		                        }
+		                      }
+		                    }
+		                  }
+		                }
+		              }`,
 						func(query string, inputs map[string]interface{}) {
 							require.Equal(t, float64(13), inputs["number"])
 							require.Equal(t, "OWNER", inputs["owner"])
@@ -624,13 +648,10 @@ func TestFind(t *testing.T) {
 				httpClient: func() (*http.Client, error) {
 					return &http.Client{Transport: reg}, nil
 				},
-				baseRepoFn:        tt.args.baseRepoFn,
-				branchFn:          tt.args.branchFn,
-				branchConfig:      tt.args.branchConfig,
-				pushDefault:       tt.args.pushDefault,
-				remotePushDefault: tt.args.remotePushDefault,
-				parsePushRevision: tt.args.parsePushRevision,
-				remotesFn: stubRemotes(context.Remotes{
+				baseRepoFn:      tt.args.baseRepoFn,
+				branchFn:        tt.args.branchFn,
+				gitConfigClient: tt.args.gitConfigClient,
+				remotesFn: stubRemotes(ghContext.Remotes{
 					&remoteOrigin,
 					&remoteOther,
 					&remoteUpstream,
@@ -667,343 +688,73 @@ func TestFind(t *testing.T) {
 	}
 }
 
-func TestParsePRRefs(t *testing.T) {
-	originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git")
-	if err != nil {
-		t.Fatal(err)
-	}
-	remoteOrigin := context.Remote{
-		Remote: &git.Remote{
-			Name:     "origin",
-			FetchURL: originOwnerUrl,
-		},
-		Repo: ghrepo.New("ORIGINOWNER", "REPO"),
-	}
-	remoteOther := context.Remote{
-		Remote: &git.Remote{
-			Name:     "other",
-			FetchURL: originOwnerUrl,
-		},
-		Repo: ghrepo.New("ORIGINOWNER", "REPO"),
+func stubBranchConfig(branchConfig git.BranchConfig, err error) func(context.Context, string) (git.BranchConfig, error) {
+	return func(_ context.Context, branch string) (git.BranchConfig, error) {
+		return branchConfig, err
 	}
+}
 
-	upstreamOwnerUrl, err := url.Parse("https://github.com/UPSTREAMOWNER/REPO.git")
-	if err != nil {
-		t.Fatal(err)
-	}
-	remoteUpstream := context.Remote{
-		Remote: &git.Remote{
-			Name:     "upstream",
-			FetchURL: upstreamOwnerUrl,
-		},
-		Repo: ghrepo.New("UPSTREAMOWNER", "REPO"),
+func stubRemotes(remotes ghContext.Remotes, err error) func() (ghContext.Remotes, error) {
+	return func() (ghContext.Remotes, error) {
+		return remotes, err
 	}
+}
 
-	tests := []struct {
-		name               string
-		branchConfig       git.BranchConfig
-		pushDefault        string
-		parsedPushRevision string
-		remotePushDefault  string
-		currentBranchName  string
-		baseRefRepo        ghrepo.Interface
-		rems               context.Remotes
-		wantPRRefs         PullRequestRefs
-		wantErr            error
-	}{
-		{
-			name:              "When the branch is called 'blueberries' with an empty branch config, it returns the correct PullRequestRefs",
-			branchConfig:      git.BranchConfig{},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name:              "When the branch is called 'otherBranch' with an empty branch config, it returns the correct PullRequestRefs",
-			branchConfig:      git.BranchConfig{},
-			currentBranchName: "otherBranch",
-			baseRefRepo:       remoteOrigin.Repo,
-			wantPRRefs: PullRequestRefs{
-				BranchName: "otherBranch",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name:               "When the branch name doesn't match the branch name in BranchConfig.Push, it returns the BranchConfig.Push branch name",
-			parsedPushRevision: "origin/pushBranch",
-			currentBranchName:  "blueberries",
-			baseRefRepo:        remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "pushBranch",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name:               "When the push revision doesn't match a remote, it returns an error",
-			parsedPushRevision: "origin/differentPushBranch",
-			currentBranchName:  "blueberries",
-			baseRefRepo:        remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteUpstream,
-				&remoteOther,
-			},
-			wantPRRefs: PullRequestRefs{},
-			wantErr:    fmt.Errorf("no remote for %q found in %q", "origin/differentPushBranch", "upstream, other"),
-		},
-		{
-			name:               "When the branch name doesn't match a different branch name in BranchConfig.Push and the remote isn't 'origin', it returns the BranchConfig.Push branch name",
-			parsedPushRevision: "other/pushBranch",
-			currentBranchName:  "blueberries",
-			baseRefRepo:        remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOther,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "pushBranch",
-				HeadRepo:   remoteOther.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the push remote is the same as the baseRepo, it returns the baseRepo as the PullRequestRefs HeadRepo",
-			branchConfig: git.BranchConfig{
-				PushRemoteName: remoteOrigin.Remote.Name,
-			},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the push remote is different from the baseRepo, it returns the push remote repo as the PullRequestRefs HeadRepo",
-			branchConfig: git.BranchConfig{
-				PushRemoteName: remoteOrigin.Remote.Name,
-			},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteUpstream.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteUpstream.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the push remote defined by a URL and the baseRepo is different from the push remote, it returns the push remote repo as the PullRequestRefs HeadRepo",
-			branchConfig: git.BranchConfig{
-				PushRemoteURL: remoteOrigin.Remote.FetchURL,
-			},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteUpstream.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteOrigin.Repo,
-				BaseRepo:   remoteUpstream.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the push remote and merge ref are configured to a different repo and push.default = upstream, it should return the branch name from the other repo",
-			branchConfig: git.BranchConfig{
-				PushRemoteName: remoteUpstream.Remote.Name,
-				MergeRef:       "refs/heads/blue-upstream-berries",
-			},
-			pushDefault:       "upstream",
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blue-upstream-berries",
-				HeadRepo:   remoteUpstream.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the push remote and merge ref are configured to a different repo and push.default = tracking, it should return the branch name from the other repo",
-			branchConfig: git.BranchConfig{
-				PushRemoteName: remoteUpstream.Remote.Name,
-				MergeRef:       "refs/heads/blue-upstream-berries",
-			},
-			pushDefault:       "tracking",
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blue-upstream-berries",
-				HeadRepo:   remoteUpstream.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name:              "When remote.pushDefault is set, it returns the correct PullRequestRefs",
-			branchConfig:      git.BranchConfig{},
-			remotePushDefault: remoteUpstream.Remote.Name,
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteUpstream.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the remote name is set on the branch, it returns the correct PullRequestRefs",
-			branchConfig: git.BranchConfig{
-				RemoteName: remoteUpstream.Remote.Name,
-			},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteUpstream.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-		{
-			name: "When the remote URL is set on the branch, it returns the correct PullRequestRefs",
-			branchConfig: git.BranchConfig{
-				RemoteURL: remoteUpstream.Remote.FetchURL,
-			},
-			currentBranchName: "blueberries",
-			baseRefRepo:       remoteOrigin.Repo,
-			rems: context.Remotes{
-				&remoteOrigin,
-				&remoteUpstream,
-			},
-			wantPRRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   remoteUpstream.Repo,
-				BaseRepo:   remoteOrigin.Repo,
-			},
-			wantErr: nil,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			prRefs, err := ParsePRRefs(tt.currentBranchName, tt.branchConfig, tt.parsedPushRevision, tt.pushDefault, tt.remotePushDefault, tt.baseRefRepo, tt.rems)
-			if tt.wantErr != nil {
-				require.Equal(t, tt.wantErr, err)
-			} else {
-				require.NoError(t, err)
-			}
-			require.Equal(t, tt.wantPRRefs, prRefs)
-		})
+func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interface, error) {
+	return func() (ghrepo.Interface, error) {
+		return baseRepo, err
 	}
 }
 
-func TestPRRefs_GetPRHeadLabel(t *testing.T) {
-	originRepo := ghrepo.New("ORIGINOWNER", "REPO")
-	upstreamRepo := ghrepo.New("UPSTREAMOWNER", "REPO")
-	tests := []struct {
-		name   string
-		prRefs PullRequestRefs
-		want   string
-	}{
-		{
-			name: "When the HeadRepo and BaseRepo match, it returns the branch name",
-			prRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   originRepo,
-				BaseRepo:   originRepo,
-			},
-			want: "blueberries",
-		},
-		{
-			name: "When the HeadRepo and BaseRepo do not match, it returns the prepended HeadRepo owner to the branch name",
-			prRefs: PullRequestRefs{
-				BranchName: "blueberries",
-				HeadRepo:   originRepo,
-				BaseRepo:   upstreamRepo,
-			},
-			want: "ORIGINOWNER:blueberries",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.want, tt.prRefs.GetPRHeadLabel())
-		})
+func stubPushDefault(pushDefault git.PushDefault, err error) func(context.Context) (git.PushDefault, error) {
+	return func(_ context.Context) (git.PushDefault, error) {
+		return pushDefault, err
 	}
 }
 
-func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) {
-	return func(branch string) (git.BranchConfig, error) {
-		return branchConfig, err
+func stubRemotePushDefault(remotePushDefault string, err error) func(context.Context) (string, error) {
+	return func(_ context.Context) (string, error) {
+		return remotePushDefault, err
 	}
 }
 
-func stubRemotes(remotes context.Remotes, err error) func() (context.Remotes, error) {
-	return func() (context.Remotes, error) {
-		return remotes, err
+func stubPushRevision(parsedPushRevision git.RemoteTrackingRef, err error) func(context.Context, string) (git.RemoteTrackingRef, error) {
+	return func(_ context.Context, _ string) (git.RemoteTrackingRef, error) {
+		return parsedPushRevision, err
 	}
 }
 
-func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interface, error) {
-	return func() (ghrepo.Interface, error) {
-		return baseRepo, err
+type stubGitConfigClient struct {
+	readBranchConfigFn  func(ctx context.Context, branchName string) (git.BranchConfig, error)
+	pushDefaultFn       func(ctx context.Context) (git.PushDefault, error)
+	remotePushDefaultFn func(ctx context.Context) (string, error)
+	pushRevisionFn      func(ctx context.Context, branchName string) (git.RemoteTrackingRef, error)
+}
+
+func (s stubGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) {
+	if s.readBranchConfigFn == nil {
+		panic("unexpected call to ReadBranchConfig")
 	}
+	return s.readBranchConfigFn(ctx, branchName)
 }
 
-func stubPushDefault(pushDefault string, err error) func() (string, error) {
-	return func() (string, error) {
-		return pushDefault, err
+func (s stubGitConfigClient) PushDefault(ctx context.Context) (git.PushDefault, error) {
+	if s.pushDefaultFn == nil {
+		panic("unexpected call to PushDefault")
 	}
+	return s.pushDefaultFn(ctx)
 }
 
-func stubRemotePushDefault(remotePushDefault string, err error) func() (string, error) {
-	return func() (string, error) {
-		return remotePushDefault, err
+func (s stubGitConfigClient) RemotePushDefault(ctx context.Context) (string, error) {
+	if s.remotePushDefaultFn == nil {
+		panic("unexpected call to RemotePushDefault")
 	}
+	return s.remotePushDefaultFn(ctx)
 }
 
-func stubParsedPushRevision(parsedPushRevision string, err error) func(string) (string, error) {
-	return func(_ string) (string, error) {
-		return parsedPushRevision, err
+func (s stubGitConfigClient) PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) {
+	if s.pushRevisionFn == nil {
+		panic("unexpected call to PushRevision")
 	}
+	return s.pushRevisionFn(ctx, branchName)
 }
diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go
index 5f5e674cc..15f00ca4f 100644
--- a/pkg/cmd/pr/shared/params_test.go
+++ b/pkg/cmd/pr/shared/params_test.go
@@ -2,13 +2,16 @@ package shared
 
 import (
 	"net/http"
+	"net/url"
 	"reflect"
 	"testing"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/httpmock"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func Test_listURLWithQuery(t *testing.T) {
@@ -265,7 +268,7 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state)
+			got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state, gh.ProjectsV1Supported)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr)
 				return
@@ -276,3 +279,144 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) {
 		})
 	}
 }
+
+// TODO projectsV1Deprecation
+// Remove this test.
+func TestWithPrAndIssueQueryParamsProjectsV1Deprecation(t *testing.T) {
+	t.Run("when projectsV1 is supported, requests them", func(t *testing.T) {
+		reg := &httpmock.Registry{}
+		client := api.NewClientFromHTTP(&http.Client{
+			Transport: reg,
+		})
+
+		repo, _ := ghrepo.FromFullName("OWNER/REPO")
+
+		reg.Register(
+			httpmock.GraphQL(`query RepositoryProjectList\b`),
+			httpmock.StringResponse(`
+		{ "data": { "repository": { "projects": {
+			"nodes": [],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		reg.Register(
+			httpmock.GraphQL(`query OrganizationProjectList\b`),
+			httpmock.StringResponse(`
+		{ "data": { "organization": { "projects": {
+			"nodes": [
+				{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1"  }
+			],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		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 }
+		} } } }
+		`))
+
+		u, err := WithPrAndIssueQueryParams(
+			client,
+			repo,
+			"http://example.com/hey",
+			IssueMetadataState{
+				ProjectTitles: []string{"Triage"},
+			},
+			gh.ProjectsV1Supported,
+		)
+		require.NoError(t, err)
+
+		url, err := url.Parse(u)
+		require.NoError(t, err)
+
+		require.Equal(
+			t,
+			url.Query().Get("projects"),
+			"ORG/1",
+		)
+	})
+
+	t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) {
+		reg := &httpmock.Registry{}
+		client := api.NewClientFromHTTP(&http.Client{
+			Transport: reg,
+		})
+
+		repo, _ := ghrepo.FromFullName("OWNER/REPO")
+
+		reg.Exclude(
+			t,
+			httpmock.GraphQL(`query RepositoryProjectList\b`),
+		)
+		reg.Exclude(
+			t,
+			httpmock.GraphQL(`query OrganizationProjectList\b`),
+		)
+
+		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": [
+				{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2"  }
+			],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+		reg.Register(
+			httpmock.GraphQL(`query UserProjectV2List\b`),
+			httpmock.StringResponse(`
+		{ "data": { "viewer": { "projectsV2": {
+			"nodes": [],
+			"pageInfo": { "hasNextPage": false }
+		} } } }
+		`))
+
+		u, err := WithPrAndIssueQueryParams(
+			client,
+			repo,
+			"http://example.com/hey",
+			IssueMetadataState{
+				ProjectTitles: []string{"TriageV2"},
+			},
+			gh.ProjectsV1Unsupported,
+		)
+		require.NoError(t, err)
+
+		url, err := url.Parse(u)
+		require.NoError(t, err)
+
+		require.Equal(
+			t,
+			url.Query().Get("projects"),
+			"ORG/2",
+		)
+	})
+}
diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go
index d74696460..6895b52ac 100644
--- a/pkg/cmd/pr/shared/survey_test.go
+++ b/pkg/cmd/pr/shared/survey_test.go
@@ -1,13 +1,16 @@
 package shared
 
 import (
+	"errors"
 	"testing"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 type metadataFetcher struct {
@@ -68,7 +71,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) {
 		Assignees: []string{"hubot"},
 		Type:      PRMetadata,
 	}
-	err := MetadataSurvey(pm, ios, repo, fetcher, state)
+	err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported)
 	assert.NoError(t, err)
 
 	assert.Equal(t, "", stdout.String())
@@ -77,7 +80,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) {
 	assert.Equal(t, []string{"hubot"}, state.Assignees)
 	assert.Equal(t, []string{"monalisa"}, state.Reviewers)
 	assert.Equal(t, []string{"good first issue"}, state.Labels)
-	assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
+	assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles)
 	assert.Equal(t, []string{}, state.Milestones)
 }
 
@@ -113,7 +116,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
 	state := &IssueMetadataState{
 		Assignees: []string{"hubot"},
 	}
-	err := MetadataSurvey(pm, ios, repo, fetcher, state)
+
+	err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported)
 	assert.NoError(t, err)
 
 	assert.Equal(t, "", stdout.String())
@@ -121,7 +125,64 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
 
 	assert.Equal(t, []string{"hubot"}, state.Assignees)
 	assert.Equal(t, []string{"good first issue"}, state.Labels)
-	assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
+	assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles)
+}
+
+// TODO projectsV1Deprecation
+// Remove this test and projectsV1MetadataFetcherSpy
+func TestMetadataSurveyProjectV1Deprecation(t *testing.T) {
+	t.Run("when projectsV1 is supported, requests projectsV1", func(t *testing.T) {
+		ios, _, _, _ := iostreams.Test()
+		repo := ghrepo.New("OWNER", "REPO")
+
+		fetcher := &projectsV1MetadataFetcherSpy{}
+		pm := prompter.NewMockPrompter(t)
+		pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) {
+			i, err := prompter.IndexFor(options, "Projects")
+			require.NoError(t, err)
+			return []int{i}, nil
+		})
+		pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) {
+			return []int{0}, nil
+		})
+
+		err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported)
+		require.ErrorContains(t, err, "expected test error")
+
+		require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested")
+	})
+
+	t.Run("when projectsV1 is supported, does not request projectsV1", func(t *testing.T) {
+		ios, _, _, _ := iostreams.Test()
+		repo := ghrepo.New("OWNER", "REPO")
+
+		fetcher := &projectsV1MetadataFetcherSpy{}
+		pm := prompter.NewMockPrompter(t)
+		pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) {
+			i, err := prompter.IndexFor(options, "Projects")
+			require.NoError(t, err)
+			return []int{i}, nil
+		})
+		pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) {
+			return []int{0}, nil
+		})
+
+		err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported)
+		require.ErrorContains(t, err, "expected test error")
+
+		require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested")
+	})
+}
+
+type projectsV1MetadataFetcherSpy struct {
+	projectsV1Requested bool
+}
+
+func (mf *projectsV1MetadataFetcherSpy) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) {
+	if input.ProjectsV1 {
+		mf.projectsV1Requested = true
+	}
+	return nil, errors.New("expected test error")
 }
 
 func TestTitledEditSurvey_cleanupHint(t *testing.T) {
diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go
index c55604c28..41c01e915 100644
--- a/pkg/cmd/pr/status/status_test.go
+++ b/pkg/cmd/pr/status/status_test.go
@@ -98,10 +98,10 @@ func TestPRStatus(t *testing.T) {
 	// stub successful git commands
 	rs, cleanup := run.Stub()
 	defer cleanup(t)
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -133,8 +133,8 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -166,8 +166,8 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{})
 	if err != nil {
@@ -198,8 +198,8 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -234,8 +234,8 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -276,8 +276,8 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -301,8 +301,8 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -326,8 +326,8 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -351,8 +351,8 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -376,8 +376,8 @@ func TestPRStatus_blankSlate(t *testing.T) {
 	defer cleanup(t)
 	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
 	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
+	rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
+	rs.Register(`git config push.default`, 1, "")
 
 	output, err := runCommand(http, "blueberries", true, "")
 	if err != nil {
@@ -432,14 +432,6 @@ func TestPRStatus_detachedHead(t *testing.T) {
 	defer http.Verify(t)
 	http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`))
 
-	// stub successful git command
-	rs, cleanup := run.Stub()
-	defer cleanup(t)
-	rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
-	rs.Register(`git config remote.pushDefault`, 0, "")
-	rs.Register(`git rev-parse --abbrev-ref @{push}`, 0, "")
-	rs.Register(`git config push.default`, 0, "")
-
 	output, err := runCommand(http, "", true, "")
 	if err != nil {
 		t.Errorf("error running command `pr status`: %v", err)
diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go
index e7f572c76..2cd4066b8 100644
--- a/pkg/cmd/pr/view/view_test.go
+++ b/pkg/cmd/pr/view/view_test.go
@@ -37,6 +37,7 @@ func TestJSONFields(t *testing.T) {
 		"changedFiles",
 		"closed",
 		"closedAt",
+		"closingIssuesReferences",
 		"comments",
 		"commits",
 		"createdAt",
diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go
index 1fed0fdc8..8ca4f14c8 100644
--- a/pkg/cmd/release/view/view_test.go
+++ b/pkg/cmd/release/view/view_test.go
@@ -144,15 +144,15 @@ func Test_viewRun(t *testing.T) {
 			wantStdout: heredoc.Doc(`
 				v1.2.3
 				MonaLisa released this about 1 day ago
-
-				                                                                                  
-				  • Fixed bugs                                                                    
-
-
+				
+				                                                                              
+				  • Fixed bugs                                                                
+				
+				
 				Assets
 				windows.zip  12 B
 				linux.tgz    34 B
-
+				
 				View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3
 			`),
 			wantStderr: ``,
@@ -169,8 +169,8 @@ func Test_viewRun(t *testing.T) {
 				v1.2.3
 				MonaLisa released this about 1 day ago
 
-				                                                                                  
-				  • Fixed bugs                                                                    
+				                                                                              
+				  • Fixed bugs                                                                
 
 
 				Assets
diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go
index c33cfdad6..5f1f17e60 100644
--- a/pkg/cmd/repo/create/create_test.go
+++ b/pkg/cmd/repo/create/create_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/gh"
+	ghmock "github.com/cli/cli/v2/internal/gh/mock"
 	"github.com/cli/cli/v2/internal/prompter"
 	"github.com/cli/cli/v2/internal/run"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -201,7 +202,7 @@ func Test_createRun(t *testing.T) {
 			name:       "interactive create from scratch with gitignore and license",
 			opts:       &CreateOptions{Interactive: true},
 			tty:        true,
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
@@ -211,7 +212,7 @@ func Test_createRun(t *testing.T) {
 						return true, nil
 					case "Would you like to add a license?":
 						return true, nil
-					case `This will create "REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "REPO" as a private repository on github.com. Continue?`:
 						return defaultValue, nil
 					case "Clone the new repository locally?":
 						return defaultValue, nil
@@ -232,7 +233,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					case "Choose a license":
@@ -267,7 +268,7 @@ func Test_createRun(t *testing.T) {
 			name:       "interactive create from scratch but with prompted owner",
 			opts:       &CreateOptions{Interactive: true},
 			tty:        true,
-			wantStdout: "✓ Created repository org1/REPO on GitHub\n  https://github.com/org1/REPO\n",
+			wantStdout: "✓ Created repository org1/REPO on github.com\n  https://github.com/org1/REPO\n",
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
@@ -277,7 +278,7 @@ func Test_createRun(t *testing.T) {
 						return false, nil
 					case "Would you like to add a license?":
 						return false, nil
-					case `This will create "org1/REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "org1/REPO" as a private repository on github.com. Continue?`:
 						return true, nil
 					case "Clone the new repository locally?":
 						return false, nil
@@ -300,7 +301,7 @@ func Test_createRun(t *testing.T) {
 					case "Repository owner":
 						return prompter.IndexFor(options, "org1")
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -345,7 +346,7 @@ func Test_createRun(t *testing.T) {
 						return false, nil
 					case "Would you like to add a license?":
 						return false, nil
-					case `This will create "REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "REPO" as a private repository on github.com. Continue?`:
 						return false, nil
 					default:
 						return false, fmt.Errorf("unexpected confirm prompt: %s", message)
@@ -364,7 +365,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
+						return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -409,7 +410,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -441,7 +442,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
 				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 		},
 		{
 			name: "interactive with existing bare repository public and push",
@@ -475,7 +476,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -509,7 +510,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
 				cs.Register(`git -C . push origin --mirror`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
 		},
 		{
 			name: "interactive with existing repository public add remote and push",
@@ -543,7 +544,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+						return prompter.IndexFor(options, "Push an existing local repository to github.com")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -577,7 +578,7 @@ func Test_createRun(t *testing.T) {
 				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
 				cs.Register(`git -C . push --set-upstream origin HEAD`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
 		},
 		{
 			name: "interactive create from a template repository",
@@ -586,7 +587,7 @@ func Test_createRun(t *testing.T) {
 			promptStubs: func(p *prompter.PrompterMock) {
 				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
 					switch message {
-					case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`:
+					case `This will create "OWNER/REPO" as a private repository on github.com. Continue?`:
 						return defaultValue, nil
 					case "Clone the new repository locally?":
 						return defaultValue, nil
@@ -611,7 +612,7 @@ func Test_createRun(t *testing.T) {
 					case "Choose a template repository":
 						return prompter.IndexFor(options, "REPO")
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
+						return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -654,7 +655,7 @@ func Test_createRun(t *testing.T) {
 			execStubs: func(cs *run.CommandStubber) {
 				cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "")
 			},
-			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
+			wantStdout: "✓ Created repository OWNER/REPO on github.com\n  https://github.com/OWNER/REPO\n",
 		},
 		{
 			name: "interactive create from template repo but there are no template repos",
@@ -680,7 +681,7 @@ func Test_createRun(t *testing.T) {
 				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
 					switch message {
 					case "What would you like to do?":
-						return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
+						return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
 					case "Visibility":
 						return prompter.IndexFor(options, "Private")
 					default:
@@ -950,6 +951,88 @@ func Test_createRun(t *testing.T) {
 			},
 			wantStdout: "https://github.com/OWNER/REPO\n",
 		},
+		{
+			name: "interactive create from scratch with host override",
+			opts: &CreateOptions{
+				Interactive: true,
+				Config: func() (gh.Config, error) {
+					cfg := &ghmock.ConfigMock{
+						AuthenticationFunc: func() gh.AuthConfig {
+							authCfg := &config.AuthConfig{}
+							authCfg.SetHosts([]string{"example.com"})
+							authCfg.SetDefaultHost("example.com", "GH_HOST")
+							return authCfg
+						},
+					}
+					return cfg, nil
+				},
+			},
+			tty: true,
+			promptStubs: func(p *prompter.PrompterMock) {
+				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
+					switch message {
+					case "Would you like to add a README file?":
+						return false, nil
+					case "Would you like to add a .gitignore?":
+						return false, nil
+					case "Would you like to add a license?":
+						return false, nil
+					case `This will create "REPO" as a private repository on example.com. Continue?`:
+						return defaultValue, nil
+					case "Clone the new repository locally?":
+						return false, nil
+					default:
+						return false, fmt.Errorf("unexpected confirm prompt: %s", message)
+					}
+				}
+				p.InputFunc = func(message, defaultValue string) (string, error) {
+					switch message {
+					case "Repository name":
+						return "REPO", nil
+					case "Description":
+						return "my new repo", nil
+					default:
+						return "", fmt.Errorf("unexpected input prompt: %s", message)
+					}
+				}
+				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
+					switch message {
+					case "What would you like to do?":
+						return prompter.IndexFor(options, "Create a new repository on example.com from scratch")
+					case "Visibility":
+						return prompter.IndexFor(options, "Private")
+					case "Choose a license":
+						return prompter.IndexFor(options, "GNU Lesser General Public License v3.0")
+					case "Choose a .gitignore template":
+						return prompter.IndexFor(options, "Go")
+					default:
+						return 0, fmt.Errorf("unexpected select prompt: %s", message)
+					}
+				}
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserCurrent\b`),
+					httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`))
+				reg.Register(
+					httpmock.GraphQL(`mutation RepositoryCreate\b`),
+					httpmock.StringResponse(`
+						{
+							"data": {
+								"createRepository": {
+									"repository": {
+										"id": "REPOID",
+										"name": "REPO",
+										"owner": {"login":"OWNER"},
+										"url": "https://example.com/OWNER/REPO"
+									}
+								}
+							}
+						}`),
+				)
+			},
+			wantStdout: "✓ Created repository OWNER/REPO on example.com\n  https://example.com/OWNER/REPO\n",
+		},
 	}
 	for _, tt := range tests {
 		prompterMock := &prompter.PrompterMock{}
@@ -965,8 +1048,11 @@ func Test_createRun(t *testing.T) {
 		tt.opts.HttpClient = func() (*http.Client, error) {
 			return &http.Client{Transport: reg}, nil
 		}
-		tt.opts.Config = func() (gh.Config, error) {
-			return config.NewBlankConfig(), nil
+
+		if tt.opts.Config == nil {
+			tt.opts.Config = func() (gh.Config, error) {
+				return config.NewBlankConfig(), nil
+			}
 		}
 
 		tt.opts.GitClient = &git.Client{
diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go
index 227e2a6a2..60e6ae392 100644
--- a/pkg/cmd/repo/sync/sync_test.go
+++ b/pkg/cmd/repo/sync/sync_test.go
@@ -470,7 +470,7 @@ func Test_SyncRun(t *testing.T) {
 			errMsg:  "trunk branch does not exist on OWNER/REPO-FORK repository",
 		},
 		{
-			name: "sync remote fork with missing workflow scope on token",
+			name: "sync remote fork with missing workflow scope on OAuth App token",
 			opts: &SyncOptions{
 				DestArg: "FORKOWNER/REPO-FORK",
 			},
@@ -485,7 +485,43 @@ func Test_SyncRun(t *testing.T) {
 					}{Message: "refusing to allow an OAuth App to create or update workflow `.github/workflows/unimportant.yml` without `workflow` scope"}))
 			},
 			wantErr: true,
-			errMsg:  "Upstream commits contain workflow changes, which require the `workflow` scope to merge. To request it, run: gh auth refresh -s workflow",
+			errMsg:  "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
+		},
+		{
+			name: "sync remote fork with missing workflow permission on GitHub App token",
+			opts: &SyncOptions{
+				DestArg: "FORKOWNER/REPO-FORK",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query RepositoryInfo\b`),
+					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
+				reg.Register(
+					httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
+					httpmock.StatusJSONResponse(422, struct {
+						Message string `json:"message"`
+					}{Message: "refusing to allow a GitHub App to create or update workflow `.github/workflows/unimportant.yml` without `workflows` permission"}))
+			},
+			wantErr: true,
+			errMsg:  "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
+		},
+		{
+			name: "sync remote fork with missing workflow scope on personal access token",
+			opts: &SyncOptions{
+				DestArg: "FORKOWNER/REPO-FORK",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query RepositoryInfo\b`),
+					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
+				reg.Register(
+					httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
+					httpmock.StatusJSONResponse(422, struct {
+						Message string `json:"message"`
+					}{Message: "refusing to allow a Personal Access Token to create or update workflow `.github/workflows/unimportant.yml` without `workflow` scope"}))
+			},
+			wantErr: true,
+			errMsg:  "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
 		},
 	}
 	for _, tt := range tests {
diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go
index d3b6d619b..f07f9de18 100644
--- a/pkg/cmd/repo/view/view_test.go
+++ b/pkg/cmd/repo/view/view_test.go
@@ -260,7 +260,7 @@ func Test_ViewRun(t *testing.T) {
 				social distancing
 
 
-				  # truly cool readme check it out                                                
+				  # truly cool readme check it out                                            
 
 
 
@@ -279,7 +279,7 @@ func Test_ViewRun(t *testing.T) {
 				social distancing
 
 
-				  # truly cool readme check it out                                                
+				  # truly cool readme check it out                                            
 
 
 
@@ -297,7 +297,7 @@ func Test_ViewRun(t *testing.T) {
 				social distancing
 
 
-				  # truly cool readme check it out                                                
+				  # truly cool readme check it out                                            
 
 
 
@@ -312,7 +312,7 @@ func Test_ViewRun(t *testing.T) {
 				social distancing
 
 
-				  # truly cool readme check it out                                                
+				  # truly cool readme check it out                                            
 
 
 
@@ -650,7 +650,7 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) {
 				Some basic special characters " & / < > '
 
 
-				  # < is always > than & ' and "                                                  
+				  # < is always > than & ' and "                                              
 
 
 
diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go
index 7717c2ff9..0f11e492a 100644
--- a/pkg/cmd/run/list/list_test.go
+++ b/pkg/cmd/run/list/list_test.go
@@ -366,6 +366,44 @@ func TestListRun(t *testing.T) {
 				completed	stale	cool commit	CI	trunk	push	10	4m34s	2021-02-23T04:51:00Z
 			`),
 		},
+		{
+			name: "org ruleset workflow in runs list shows with empty workflow name",
+			opts: &ListOptions{
+				Limit: defaultLimit,
+				now:   shared.TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+			},
+			isTTY: true,
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRunsWithOrgRequiredWorkflows,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/456"),
+					httpmock.StatusStringResponse(404, "not found"),
+				)
+			},
+			wantOut: heredoc.Doc(`
+				STATUS  TITLE        WORKFLOW  BRANCH  EVENT  ID  ELAPSED  AGE
+				X       cool commit            trunk   push   1   4m34s    about 4 minutes ago
+				*       cool commit            trunk   push   2   4m34s    about 4 minutes ago
+				✓       cool commit            trunk   push   3   4m34s    about 4 minutes ago
+				X       cool commit            trunk   push   4   4m34s    about 4 minutes ago
+				X       cool commit  CI        trunk   push   5   4m34s    about 4 minutes ago
+				-       cool commit  CI        trunk   push   6   4m34s    about 4 minutes ago
+				-       cool commit  CI        trunk   push   7   4m34s    about 4 minutes ago
+				*       cool commit  CI        trunk   push   8   4m34s    about 4 minutes ago
+				*       cool commit  CI        trunk   push   9   4m34s    about 4 minutes ago
+			`),
+		},
 		{
 			name: "pagination",
 			opts: &ListOptions{
diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go
index 7caa37039..5a8a4584e 100644
--- a/pkg/cmd/run/shared/test.go
+++ b/pkg/cmd/run/shared/test.go
@@ -18,6 +18,10 @@ func TestRunWithCommit(id int64, s Status, c Conclusion, commit string) Run {
 	return TestRunWithWorkflowAndCommit(123, id, s, c, commit)
 }
 
+func TestRunWithOrgRequiredWorkflow(id int64, s Status, c Conclusion, commit string) Run {
+	return TestRunWithWorkflowAndCommit(456, id, s, c, commit)
+}
+
 func TestRunWithWorkflowAndCommit(workflowId, runId int64, s Status, c Conclusion, commit string) Run {
 	return Run{
 		WorkflowID: workflowId,
@@ -57,6 +61,18 @@ var TestRuns []Run = []Run{
 	TestRun(10, Completed, Stale),
 }
 
+var TestRunsWithOrgRequiredWorkflows []Run = []Run{
+	TestRunWithOrgRequiredWorkflow(1, Completed, TimedOut, "cool commit"),
+	TestRunWithOrgRequiredWorkflow(2, InProgress, "", "cool commit"),
+	TestRunWithOrgRequiredWorkflow(3, Completed, Success, "cool commit"),
+	TestRunWithOrgRequiredWorkflow(4, Completed, Cancelled, "cool commit"),
+	TestRun(5, Completed, Failure),
+	TestRun(6, Completed, Neutral),
+	TestRun(7, Completed, Skipped),
+	TestRun(8, Requested, ""),
+	TestRun(9, Queued, ""),
+}
+
 var WorkflowRuns []Run = []Run{
 	TestRun(2, InProgress, ""),
 	SuccessfulRun,
@@ -88,6 +104,60 @@ var SuccessfulJob Job = Job{
 	},
 }
 
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var SuccessfulJobWithoutStepLogs Job = Job{
+	ID:          11,
+	Status:      Completed,
+	Conclusion:  Success,
+	Name:        "cool job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/11",
+	RunID:       3,
+	Steps: []Step{
+		{
+			Name:       "fob the barz",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "barz the fob",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     2,
+		},
+	},
+}
+
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var LegacySuccessfulJobWithoutStepLogs Job = Job{
+	ID:          12,
+	Status:      Completed,
+	Conclusion:  Success,
+	Name:        "legacy cool job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/12",
+	RunID:       3,
+	Steps: []Step{
+		{
+			Name:       "fob the barz",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "barz the fob",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     2,
+		},
+	},
+}
+
 var FailedJob Job = Job{
 	ID:          20,
 	Status:      Completed,
@@ -113,6 +183,60 @@ var FailedJob Job = Job{
 	},
 }
 
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var FailedJobWithoutStepLogs Job = Job{
+	ID:          21,
+	Status:      Completed,
+	Conclusion:  Failure,
+	Name:        "sad job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/21",
+	RunID:       1234,
+	Steps: []Step{
+		{
+			Name:       "barf the quux",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "quux the barf",
+			Status:     Completed,
+			Conclusion: Failure,
+			Number:     2,
+		},
+	},
+}
+
+// Note that this run *has* steps, but in the ZIP archive the step logs are not
+// included.
+var LegacyFailedJobWithoutStepLogs Job = Job{
+	ID:          22,
+	Status:      Completed,
+	Conclusion:  Failure,
+	Name:        "legacy sad job with no step logs",
+	StartedAt:   TestRunStartTime,
+	CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
+	URL:         "https://github.com/jobs/22",
+	RunID:       1234,
+	Steps: []Step{
+		{
+			Name:       "barf the quux",
+			Status:     Completed,
+			Conclusion: Success,
+			Number:     1,
+		},
+		{
+			Name:       "quux the barf",
+			Status:     Completed,
+			Conclusion: Failure,
+			Number:     2,
+		},
+	},
+}
+
 var SuccessfulJobAnnotations []Annotation = []Annotation{
 	{
 		JobName:   "cool job",
diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go
index 3a04fb186..2d150934f 100644
--- a/pkg/cmd/run/view/view_test.go
+++ b/pkg/cmd/run/view/view_test.go
@@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) {
 			},
 			wantOut: quuxTheBarfLogOutput,
 		},
+		{
+			name: "interactive with log, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool job with no step logs")
+					})
+			},
+			wantOut: coolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID: "11",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"),
+					httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: coolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with log-failed, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X sad job with no step logs")
+					})
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log-failed, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID:     "21",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"),
+					httpmock.JSONResponse(shared.FailedJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with run log, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: expectedRunLogOutputWithNoSteps,
+		},
+		{
+			name: "noninteractive with run log, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID: "3",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: expectedRunLogOutputWithNoSteps,
+		},
+		{
+			name: "interactive with run log-failed, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with run log-failed, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID:     "1234",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.SuccessfulJobWithoutStepLogs,
+							shared.FailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: sadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with log, legacy service data, with no step logs available",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ legacy cool job with no step logs")
+					})
+			},
+			wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log, legacy service data, with no step logs available",
+			opts: &ViewOptions{
+				JobID: "12",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"),
+					httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
+		},
+
+		{
+			name: "interactive with log-failed, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "X legacy sad job with no step logs")
+					})
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				JobID:     "22",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"),
+					httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "interactive with run log, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt: true,
+				Log:    true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: expectedLegacyRunLogOutputWithNoSteps,
+		},
+		{
+			name: "noninteractive with run log, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID: "3",
+				Log:   true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
+					httpmock.JSONResponse(shared.SuccessfulRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/3/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: expectedLegacyRunLogOutputWithNoSteps,
+		},
+		{
+			name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)",
+			tty:  true,
+			opts: &ViewOptions{
+				Prompt:    true,
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
+					httpmock.JSONResponse(shared.RunsPayload{
+						WorkflowRuns: shared.TestRuns,
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.JSONResponse(workflowShared.WorkflowsPayload{
+						Workflows: []workflowShared.Workflow{
+							shared.TestWorkflow,
+						},
+					}))
+			},
+			promptStubs: func(pm *prompter.MockPrompter) {
+				pm.RegisterSelect("Select a workflow run",
+					[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
+					func(_, _ string, opts []string) (int, error) {
+						return 4, nil
+					})
+				pm.RegisterSelect("View a specific job in this run?",
+					[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, "View all jobs in this run")
+					})
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
+		{
+			name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)",
+			opts: &ViewOptions{
+				RunID:     "1234",
+				LogFailed: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
+					httpmock.JSONResponse(shared.FailedRun))
+				reg.Register(
+					httpmock.REST("GET", "runs/1234/jobs"),
+					httpmock.JSONResponse(shared.JobsPayload{
+						Jobs: []shared.Job{
+							shared.LegacySuccessfulJobWithoutStepLogs,
+							shared.LegacyFailedJobWithoutStepLogs,
+						},
+					}))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
+					httpmock.FileResponse("./fixtures/run_log.zip"))
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
+					httpmock.JSONResponse(shared.TestWorkflow))
+			},
+			wantOut: legacySadJobRunWithNoStepLogsLogOutput,
+		},
 		{
 			name: "run log but run is not done",
 			tty:  true,
@@ -1419,14 +2032,24 @@ func TestViewRun(t *testing.T) {
 //	├── sad job/
 //	│   ├── 1_barf the quux.txt
 //	│   └── 2_quux the barf.txt
-//	└── ad job/
-//	    └── 1_barf the quux.txt
+//	├── ad job/
+//	|   └── 1_barf the quux.txt
+//	├── 0_cool job.txt
+//	├── 1_sad job.txt
+//	├── 2_cool job with no step logs.txt
+//	├── 3_sad job with no step logs.txt
+//	├── -9999999999_legacy cool job with no step logs.txt
+//	├── -9999999999_legacy sad job with no step logs.txt
+//	├── 4_cool job with both legacy and new logs.txt
+//	└── -9999999999_cool job with both legacy and new logs.txt
 func Test_attachRunLog(t *testing.T) {
 	tests := []struct {
-		name         string
-		job          shared.Job
-		wantMatch    bool
-		wantFilename string
+		name             string
+		job              shared.Job
+		wantJobMatch     bool
+		wantJobFilename  string
+		wantStepMatch    bool
+		wantStepFilename string
 	}{
 		{
 			name: "matching job name and step number 1",
@@ -1437,8 +2060,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/1_fob the barz.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/1_fob the barz.txt",
 		},
 		{
 			name: "matching job name and step number 2",
@@ -1449,8 +2074,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 2,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/2_barz the fob.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/2_barz the fob.txt",
 		},
 		{
 			name: "matching job name and step number and mismatch step name",
@@ -1461,8 +2088,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job/1_fob the barz.txt",
+			wantJobMatch:     true,
+			wantJobFilename:  "0_cool job.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "cool job/1_fob the barz.txt",
 		},
 		{
 			name: "matching job name and mismatch step number",
@@ -1473,7 +2102,62 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 3,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:    true,
+			wantJobFilename: "0_cool job.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with no step logs",
+			job: shared.Job{
+				Name: "cool job with no step logs",
+				Steps: []shared.Step{{
+					Name:   "fob the barz",
+					Number: 1,
+				}},
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "2_cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with no step data",
+			job: shared.Job{
+				Name: "cool job with no step logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "2_cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with legacy filename and no step logs",
+			job: shared.Job{
+				Name: "legacy cool job with no step logs",
+				Steps: []shared.Step{{
+					Name:   "fob the barz",
+					Number: 1,
+				}},
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with legacy filename and no step data",
+			job: shared.Job{
+				Name: "legacy cool job with no step logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
+			wantStepMatch:   false,
+		},
+		{
+			name: "matching job name with both normal and legacy filename",
+			job: shared.Job{
+				Name: "cool job with both legacy and new logs",
+			},
+			wantJobMatch:    true,
+			wantJobFilename: "4_cool job with both legacy and new logs.txt",
+			wantStepMatch:   false,
 		},
 		{
 			name: "one job name is a suffix of another",
@@ -1484,8 +2168,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "ad job/1_barf the quux.txt",
+			wantStepMatch:    true,
+			wantStepFilename: "ad job/1_barf the quux.txt",
 		},
 		{
 			name: "escape metacharacters in job name",
@@ -1496,7 +2180,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 0,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:  false,
+			wantStepMatch: false,
 		},
 		{
 			name: "mismatching job name",
@@ -1507,7 +2192,8 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch: false,
+			wantJobMatch:  false,
+			wantStepMatch: false,
 		},
 		{
 			name: "job name with forward slash matches dir with slash removed",
@@ -1518,9 +2204,10 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch: true,
+			wantJobMatch:  false,
+			wantStepMatch: true,
 			// not the double space in the dir name, as the slash has been removed
-			wantFilename: "cool job  with slash/1_fob the barz.txt",
+			wantStepFilename: "cool job  with slash/1_fob the barz.txt",
 		},
 		{
 			name: "job name with colon matches dir with colon removed",
@@ -1531,8 +2218,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "cool job  with colon/1_fob the barz.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "cool job  with colon/1_fob the barz.txt",
 		},
 		{
 			name: "Job name with really long name (over the ZIP limit)",
@@ -1543,8 +2231,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
 		},
 		{
 			name: "Job name that would be truncated by the C# server to split a grapheme",
@@ -1555,8 +2244,9 @@ func Test_attachRunLog(t *testing.T) {
 					Number: 1,
 				}},
 			},
-			wantMatch:    true,
-			wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt",
+			wantJobMatch:     false,
+			wantStepMatch:    true,
+			wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt",
 		},
 	}
 
@@ -1566,17 +2256,27 @@ func Test_attachRunLog(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			jobs := []shared.Job{tt.job}
 
-			attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job})
+			attachRunLog(&run_log_zip_reader.Reader, jobs)
 
 			t.Logf("Job details: ")
 
-			for _, step := range tt.job.Steps {
-				log := step.Log
-				logPresent := log != nil
-				require.Equal(t, tt.wantMatch, logPresent, "log not present")
-				if logPresent {
-					require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch")
+			job := jobs[0]
+
+			jobLog := job.Log
+			jobLogPresent := jobLog != nil
+			require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present")
+			if jobLogPresent {
+				require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch")
+			}
+
+			for _, step := range job.Steps {
+				stepLog := step.Log
+				stepLogPresent := stepLog != nil
+				require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present")
+				if stepLogPresent {
+					require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch")
 				}
 			}
 		})
@@ -1607,9 +2307,35 @@ sad job	quux the barf	log line 2
 sad job	quux the barf	log line 3
 `)
 
+var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+cool job with no step logs	UNKNOWN STEP	log line 1
+cool job with no step logs	UNKNOWN STEP	log line 2
+cool job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+legacy cool job with no step logs	UNKNOWN STEP	log line 1
+legacy cool job with no step logs	UNKNOWN STEP	log line 2
+legacy cool job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+sad job with no step logs	UNKNOWN STEP	log line 1
+sad job with no step logs	UNKNOWN STEP	log line 2
+sad job with no step logs	UNKNOWN STEP	log line 3
+`)
+
+var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
+legacy sad job with no step logs	UNKNOWN STEP	log line 1
+legacy sad job with no step logs	UNKNOWN STEP	log line 2
+legacy sad job with no step logs	UNKNOWN STEP	log line 3
+`)
+
 var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput)
 var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput)
 var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput)
+var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput)
+var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput)
 
 func TestRunLog(t *testing.T) {
 	t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) {
diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go
index 377879cf2..48200b881 100644
--- a/pkg/cmd/secret/delete/delete_test.go
+++ b/pkg/cmd/secret/delete/delete_test.go
@@ -126,7 +126,7 @@ func TestNewCmdDelete(t *testing.T) {
 }
 
 func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
-	remotes := ghContext.Remotes{
+	multipleRemotes := ghContext.Remotes{
 		&ghContext.Remote{
 			Remote: &git.Remote{
 				Name: "origin",
@@ -141,9 +141,20 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 		},
 	}
 
+	singleRemote := ghContext.Remotes{
+		&ghContext.Remote{
+			Remote: &git.Remote{
+				Name: "origin",
+			},
+			Repo: ghrepo.New("owner", "repo"),
+		},
+	}
+
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
+		remotes       ghContext.Remotes
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -151,18 +162,36 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 		{
 			name:     "when there is a repo flag provided, the factory base repo func is used",
 			args:     "SECRET_NAME --repo owner/repo",
+			remotes:  multipleRemotes,
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
 			args: "SECRET_NAME",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			remotes:  multipleRemotes,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
+			args:    "SECRET_NAME",
+			remotes: multipleRemotes,
 			wantErr: shared.AmbiguousBaseRepoError{
-				Remotes: remotes,
+				Remotes: multipleRemotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
-			args: "SECRET_NAME",
+			name:     "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
+			args:     "SECRET_NAME",
+			remotes:  singleRemote,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
+			args:    "SECRET_NAME",
+			remotes: multipleRemotes,
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
 					"Select a repo",
@@ -195,10 +224,14 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 				},
 				Prompter: pm,
 				Remotes: func() (ghContext.Remotes, error) {
-					return remotes, nil
+					return tt.remotes, nil
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)
 
diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go
index 601de2572..5c4dd4874 100644
--- a/pkg/cmd/secret/list/list_test.go
+++ b/pkg/cmd/secret/list/list_test.go
@@ -106,7 +106,7 @@ func Test_NewCmdList(t *testing.T) {
 }
 
 func TestNewCmdListBaseRepoFuncs(t *testing.T) {
-	remotes := ghContext.Remotes{
+	multipleRemotes := ghContext.Remotes{
 		&ghContext.Remote{
 			Remote: &git.Remote{
 				Name: "origin",
@@ -121,9 +121,20 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 		},
 	}
 
+	singleRemote := ghContext.Remotes{
+		&ghContext.Remote{
+			Remote: &git.Remote{
+				Name: "origin",
+			},
+			Repo: ghrepo.New("owner", "repo"),
+		},
+	}
+
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
+		remotes       ghContext.Remotes
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -131,18 +142,35 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 		{
 			name:     "when there is a repo flag provided, the factory base repo func is used",
 			args:     "--repo owner/repo",
+			remotes:  multipleRemotes,
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
-			args: "",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			remotes:  multipleRemotes,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
+			args:    "",
+			remotes: multipleRemotes,
 			wantErr: shared.AmbiguousBaseRepoError{
-				Remotes: remotes,
+				Remotes: multipleRemotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
-			args: "",
+			name:     "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
+			args:     "",
+			remotes:  singleRemote,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
+			args:    "",
+			remotes: multipleRemotes,
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
 					"Select a repo",
@@ -175,10 +203,14 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 				},
 				Prompter: pm,
 				Remotes: func() (ghContext.Remotes, error) {
-					return remotes, nil
+					return tt.remotes, nil
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)
 
diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go
index 99c8c6cbe..0b305eda6 100644
--- a/pkg/cmd/secret/set/set_test.go
+++ b/pkg/cmd/secret/set/set_test.go
@@ -225,7 +225,7 @@ func TestNewCmdSet(t *testing.T) {
 }
 
 func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
-	remotes := ghContext.Remotes{
+	multipleRemotes := ghContext.Remotes{
 		&ghContext.Remote{
 			Remote: &git.Remote{
 				Name: "origin",
@@ -240,9 +240,20 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 		},
 	}
 
+	singleRemote := ghContext.Remotes{
+		&ghContext.Remote{
+			Remote: &git.Remote{
+				Name: "origin",
+			},
+			Repo: ghrepo.New("owner", "repo"),
+		},
+	}
+
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
+		remotes       ghContext.Remotes
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -250,18 +261,36 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 		{
 			name:     "when there is a repo flag provided, the factory base repo func is used",
 			args:     "SECRET_NAME --repo owner/repo",
+			remotes:  multipleRemotes,
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
 			args: "SECRET_NAME",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			remotes:  multipleRemotes,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
+			args:    "SECRET_NAME",
+			remotes: multipleRemotes,
 			wantErr: shared.AmbiguousBaseRepoError{
-				Remotes: remotes,
+				Remotes: multipleRemotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
-			args: "SECRET_NAME",
+			name:     "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
+			args:     "SECRET_NAME",
+			remotes:  singleRemote,
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name:    "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
+			args:    "SECRET_NAME",
+			remotes: multipleRemotes,
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
 					"Select a repo",
@@ -294,10 +323,14 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 				},
 				Prompter: pm,
 				Remotes: func() (ghContext.Remotes, error) {
-					return remotes, nil
+					return tt.remotes, nil
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)
 
diff --git a/pkg/cmdutil/args_test.go b/pkg/cmdutil/args_test.go
index db0b96510..4e880dd27 100644
--- a/pkg/cmdutil/args_test.go
+++ b/pkg/cmdutil/args_test.go
@@ -1,6 +1,14 @@
 package cmdutil
 
-import "testing"
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
 
 func TestMinimumArgs(t *testing.T) {
 	tests := []struct {
@@ -48,3 +56,207 @@ func TestMinimumNs_with_error(t *testing.T) {
 		}
 	}
 }
+
+func TestPartition(t *testing.T) {
+	tests := []struct {
+		name            string
+		slice           []any
+		predicate       func(any) bool
+		wantMatching    []any
+		wantNonMatching []any
+	}{
+		{
+			name:  "When the slice is empty, it returns two empty slices",
+			slice: []any{},
+			predicate: func(any) bool {
+				return true
+			},
+			wantMatching:    []any{},
+			wantNonMatching: []any{},
+		},
+		{
+			name: "when the slice has one element that satisfies the predicate, it returns a slice with that element and an empty slice",
+			slice: []any{
+				"foo",
+			},
+			predicate: func(any) bool {
+				return true
+			},
+			wantMatching:    []any{"foo"},
+			wantNonMatching: []any{},
+		},
+		{
+			name: "when the slice has one element that does not satisfy the predicate, it returns an empty slice and a slice with that element",
+			slice: []any{
+				"foo",
+			},
+			predicate: func(any) bool {
+				return false
+			},
+			wantMatching:    []any{},
+			wantNonMatching: []any{"foo"},
+		},
+		{
+			name: "when the slice has multiple elements, it returns a slice with the elements that satisfy the predicate and a slice with the elements that do not satisfy the predicate",
+			slice: []any{
+				"foo",
+				"bar",
+				"baz",
+			},
+			predicate: func(s any) bool {
+				return s.(string) != "foo"
+			},
+			wantMatching:    []any{"bar", "baz"},
+			wantNonMatching: []any{"foo"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotMatching, gotNonMatching := Partition(tt.slice, tt.predicate)
+			assert.ElementsMatch(t, tt.wantMatching, gotMatching)
+			assert.ElementsMatch(t, tt.wantNonMatching, gotNonMatching)
+		})
+	}
+}
+
+func TestGlobPaths(t *testing.T) {
+	tests := []struct {
+		name     string
+		os       string
+		patterns []string
+		wantOut  []string
+		wantErr  error
+	}{
+		{
+			name:     "When no patterns are passed, return an empty slice",
+			patterns: []string{},
+			wantOut:  []string{},
+			wantErr:  nil,
+		},
+		{
+			name:     "When no files match, it returns an empty expansions array with error",
+			patterns: []string{"foo"},
+			wantOut:  []string{},
+			wantErr:  errors.New("no matches found for `foo`"),
+		},
+		{
+			name: "When a single pattern, '*.txt' is passed with one match, it returns that match",
+			patterns: []string{
+				"*.txt",
+			},
+			wantOut: []string{
+				"rootFile.txt",
+			},
+			wantErr: nil,
+		},
+		{
+			name: "When a single pattern, '*/*.txt' is passed with multiple matches, it returns those matches",
+			patterns: []string{
+				"*/*.txt",
+			},
+			wantOut: []string{
+				filepath.Join("subDir1", "subDir1_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.txt"),
+			},
+			wantErr: nil,
+		},
+		{
+			name: "When multiple patterns, '*/*.txt' and '*/*.go', are passed with multiple matches, it returns those matches",
+			patterns: []string{
+				"*/*.txt",
+				"*/*.go",
+			},
+			wantOut: []string{
+				filepath.Join("subDir1", "subDir1_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.go"),
+			},
+			wantErr: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cleanupFn := createTestDir(t)
+			defer cleanupFn()
+
+			got, err := GlobPaths(tt.patterns)
+			if tt.wantErr != nil {
+				assert.EqualError(t, err, tt.wantErr.Error())
+			} else {
+				require.NoError(t, err)
+			}
+			assert.Equal(t, tt.wantOut, got)
+		})
+	}
+}
+
+// Creates a temporary directory with the structure below. Returns
+// a cleanup function that will remove the directory and all of its
+// contents. The cleanup function should be wrapped in a defer statement.
+//
+//	| root
+//	|-- rootFile.txt
+//	|-- subDir1
+//	|	|-- subDir1_file.txt
+//	|
+//	|-- subDir2
+//		|-- subDir2_file.go
+//		|-- subDir2_file.txt
+func createTestDir(t *testing.T) (cleanupFn func()) {
+	t.Helper()
+	// Make Directories
+	rootDir := t.TempDir()
+
+	// Move workspace to temporary directory
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = os.Chdir(rootDir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Make subdirectories
+	err = os.Mkdir(filepath.Join(rootDir, "subDir1"), 0755)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.Mkdir(filepath.Join(rootDir, "subDir2"), 0755)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Make Files
+	err = os.WriteFile(filepath.Join(rootDir, "rootFile.txt"), []byte(""), 0644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir1", "subDir1_file.txt"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.go"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.txt"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	cleanupFn = func() {
+		os.RemoveAll(rootDir)
+		err = os.Chdir(cwd)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+	return cleanupFn
+}
diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go
index 59fea53ed..f6a72e2a7 100644
--- a/pkg/iostreams/color_test.go
+++ b/pkg/iostreams/color_test.go
@@ -1,12 +1,13 @@
 package iostreams
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
-func TestColorFromRGB(t *testing.T) {
+func TestLabel(t *testing.T) {
 	tests := []struct {
 		name  string
 		hex   string
@@ -19,77 +20,277 @@ func TestColorFromRGB(t *testing.T) {
 			hex:   "fc0303",
 			text:  "red",
 			wants: "\033[38;2;252;3;3mred\033[0m",
-			cs:    NewColorScheme(true, true, true),
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				ColorLabels:   true,
+			},
 		},
 		{
 			name:  "no truecolor",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(true, true, false),
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				ColorLabels:   true,
+			},
 		},
 		{
 			name:  "no color",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs: &ColorScheme{
+				ColorLabels: true,
+			},
 		},
 		{
 			name:  "invalid hex",
 			hex:   "fc0",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs: &ColorScheme{
+				ColorLabels: true,
+			},
+		},
+		{
+			name:  "no color labels",
+			hex:   "fc0303",
+			text:  "red",
+			wants: "red",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				ColorLabels:   true,
+			},
 		},
 	}
 
 	for _, tt := range tests {
-		fn := tt.cs.ColorFromRGB(tt.hex)
-		assert.Equal(t, tt.wants, fn(tt.text))
+		output := tt.cs.Label(tt.hex, tt.text)
+		assert.Equal(t, tt.wants, output)
 	}
 }
 
-func TestHexToRGB(t *testing.T) {
+func TestTableHeader(t *testing.T) {
+	reset := "\x1b[0m"
+	defaultUnderline := "\x1b[0;4;39m"
+	brightBlackUnderline := "\x1b[0;4;90m"
+	dimBlackUnderline := "\x1b[0;2;4;37m"
+
 	tests := []struct {
-		name  string
-		hex   string
-		text  string
-		wants string
-		cs    *ColorScheme
+		name     string
+		cs       *ColorScheme
+		input    string
+		expected string
 	}{
 		{
-			name:  "truecolor",
-			hex:   "fc0303",
-			text:  "red",
-			wants: "\033[38;2;252;3;3mred\033[0m",
-			cs:    NewColorScheme(true, true, true),
+			name: "when color is disabled, text is not stylized",
+			cs: &ColorScheme{
+				Accessible: true,
+				Theme:      NoTheme,
+			},
+			input:    "this should not be stylized",
+			expected: "this should not be stylized",
 		},
 		{
-			name:  "no truecolor",
-			hex:   "fc0303",
-			text:  "red",
-			wants: "red",
-			cs:    NewColorScheme(true, true, false),
+			name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs: &ColorScheme{
+				Enabled:    true,
+				Accessible: true,
+				Theme:      NoTheme,
+			},
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
 		},
 		{
-			name:  "no color",
-			hex:   "fc0303",
-			text:  "red",
-			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs: &ColorScheme{
+				Enabled:    true,
+				Accessible: true,
+				Theme:      LightTheme,
+			},
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
 		},
 		{
-			name:  "invalid hex",
-			hex:   "fc0",
-			text:  "red",
-			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used",
+			cs: &ColorScheme{
+				Enabled:    true,
+				Accessible: true,
+				Theme:      DarkTheme,
+			},
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
+		},
+		{
+			name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				Accessible:    true,
+				Theme:         NoTheme,
+			},
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
+		},
+		{
+			name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				Accessible:    true,
+				Theme:         LightTheme,
+			},
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
+		},
+		{
+			name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				Accessible:    true,
+				Theme:         DarkTheme,
+			},
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
+		},
+		{
+			name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         NoTheme,
+			},
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
+		},
+		{
+			name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         LightTheme,
+			},
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
+		},
+		{
+			name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         DarkTheme,
+			},
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
 		},
 	}
 
 	for _, tt := range tests {
-		output := tt.cs.HexToRGB(tt.hex, tt.text)
-		assert.Equal(t, tt.wants, output)
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, tt.cs.TableHeader(tt.input))
+		})
+	}
+}
+
+func TestMuted(t *testing.T) {
+	reset := "\x1b[0m"
+	gray4bit := "\x1b[0;90m"
+	gray8bit := "\x1b[38;5;242m"
+	brightBlack4bit := "\x1b[0;90m"
+	dimBlack4bit := "\x1b[0;2;37m"
+
+	tests := []struct {
+		name     string
+		cs       *ColorScheme
+		input    string
+		expected string
+	}{
+		{
+			name:     "when color is disabled but accessible colors are disabled, text is not stylized",
+			cs:       &ColorScheme{},
+			input:    "this should not be stylized",
+			expected: "this should not be stylized",
+		},
+		{
+			name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used",
+			cs: &ColorScheme{
+				Enabled: true,
+			},
+			input:    "this should be 4-bit gray",
+			expected: fmt.Sprintf("%sthis should be 4-bit gray%s", gray4bit, reset),
+		},
+		{
+			name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+			},
+			input:    "this should be 8-bit gray",
+			expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset),
+		},
+		{
+			name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+			},
+			input:    "this should be 8-bit gray",
+			expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset),
+		},
+		{
+			name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         DarkTheme,
+			},
+			input:    "this should be 4-bit dim black",
+			expected: fmt.Sprintf("%sthis should be 4-bit dim black%s", dimBlack4bit, reset),
+		},
+		{
+			name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         LightTheme,
+			},
+			input:    "this should be 4-bit bright black",
+			expected: fmt.Sprintf("%sthis should be 4-bit bright black%s", brightBlack4bit, reset),
+		},
+		{
+			name: "when 4-bit color is enabled but no theme, 4-bit default color is used",
+			cs: &ColorScheme{
+				Enabled:       true,
+				EightBitColor: true,
+				TrueColor:     true,
+				Accessible:    true,
+				Theme:         NoTheme,
+			},
+			input:    "this should have no explicit color",
+			expected: "this should have no explicit color",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, tt.cs.Muted(tt.input))
+		})
 	}
 }
diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go
new file mode 100644
index 000000000..60d0ece91
--- /dev/null
+++ b/pkg/iostreams/iostreams_progress_indicator_test.go
@@ -0,0 +1,254 @@
+//go:build !windows
+
+package iostreams
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Netflix/go-expect"
+	"github.com/creack/pty"
+	"github.com/hinshun/vt10x"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStartProgressIndicatorWithLabel(t *testing.T) {
+	osOut := os.Stdout
+	defer func() { os.Stdout = osOut }()
+	// Why do we need a channel in these tests to implement a timeout instead of
+	// relying on expect's timeout?
+	//
+	// Well, expect's timeout is based on the maximum time of a single read
+	// from the console. This works in cases like prompting where we block
+	// waiting for input because the console is not ready to be read.
+	// But in this case, we are not blocking waiting for input and stdout
+	// can be constantly read. This means the timeout will never be reached
+	// in the event of a expectation failure.
+	// To fix this, we need to implement our own timeout that is based
+	// specifically on the total time spent reading the console and waiting
+	// for the target string instead of the max time for a single read
+	// from the console.
+	t.Run("progress indicator respects GH_SPINNER_DISABLED is true", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		io := newTestIOStreams(t, console, true)
+
+		done := make(chan error)
+
+		go func() {
+			_, err := console.ExpectString("Working...")
+			done <- err
+		}()
+
+		io.StartProgressIndicatorWithLabel("")
+		defer io.StopProgressIndicator()
+
+		select {
+		case err := <-done:
+			require.NoError(t, err)
+		case <-time.After(2 * time.Second):
+			t.Fatal("Test timed out waiting for progress indicator")
+		}
+	})
+
+	t.Run("progress indicator respects GH_SPINNER_DISABLED is false", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		io := newTestIOStreams(t, console, false)
+
+		done := make(chan error)
+
+		go func() {
+			_, err := console.ExpectString("⣾")
+			done <- err
+		}()
+
+		io.StartProgressIndicatorWithLabel("")
+		defer io.StopProgressIndicator()
+
+		select {
+		case err := <-done:
+			require.NoError(t, err)
+		case <-time.After(2 * time.Second):
+			t.Fatal("Test timed out waiting for progress indicator")
+		}
+	})
+
+	t.Run("progress indicator with GH_SPINNER_DISABLED shows label", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		io := newTestIOStreams(t, console, true)
+		progressIndicatorLabel := "downloading happiness"
+
+		done := make(chan error)
+
+		go func() {
+			_, err := console.ExpectString(progressIndicatorLabel + "...")
+			done <- err
+		}()
+
+		io.StartProgressIndicatorWithLabel(progressIndicatorLabel)
+		defer io.StopProgressIndicator()
+
+		select {
+		case err := <-done:
+			require.NoError(t, err)
+		case <-time.After(2 * time.Second):
+			t.Fatal("Test timed out waiting for progress indicator")
+		}
+	})
+
+	t.Run("progress indicator shows label and spinner", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		io := newTestIOStreams(t, console, false)
+		progressIndicatorLabel := "downloading happiness"
+
+		done := make(chan error)
+
+		go func() {
+			_, err := console.ExpectString(progressIndicatorLabel)
+			require.NoError(t, err)
+			_, err = console.ExpectString("⣾")
+			done <- err
+		}()
+
+		io.StartProgressIndicatorWithLabel(progressIndicatorLabel)
+		defer io.StopProgressIndicator()
+
+		select {
+		case err := <-done:
+			require.NoError(t, err)
+		case <-time.After(2 * time.Second):
+			t.Fatal("Test timed out waiting for progress indicator")
+		}
+	})
+
+	t.Run("multiple calls to start progress indicator with GH_SPINNER_DISABLED prints additional labels", func(t *testing.T) {
+		console := newTestVirtualTerminal(t)
+		io := newTestIOStreams(t, console, true)
+		progressIndicatorLabel1 := "downloading happiness"
+		progressIndicatorLabel2 := "downloading sadness"
+		done := make(chan error)
+		go func() {
+			_, err := console.ExpectString(progressIndicatorLabel1 + "...")
+			require.NoError(t, err)
+			_, err = console.ExpectString(progressIndicatorLabel2 + "...")
+			done <- err
+		}()
+		io.StartProgressIndicatorWithLabel(progressIndicatorLabel1)
+		defer io.StopProgressIndicator()
+		io.StartProgressIndicatorWithLabel(progressIndicatorLabel2)
+
+		select {
+		case err := <-done:
+			require.NoError(t, err)
+		case <-time.After(2 * time.Second):
+			t.Fatal("Test timed out waiting for progress indicator")
+		}
+	})
+}
+
+func newTestVirtualTerminal(t *testing.T) *expect.Console {
+	t.Helper()
+
+	// Create a PTY and hook up a virtual terminal emulator
+	ptm, pts, err := pty.Open()
+	require.NoError(t, err)
+
+	term := vt10x.New(vt10x.WithWriter(pts))
+
+	// Create a console via Expect that allows scripting against the terminal
+	consoleOpts := []expect.ConsoleOpt{
+		expect.WithStdin(ptm),
+		expect.WithStdout(term),
+		expect.WithCloser(ptm, pts),
+		failOnExpectError(t),
+		failOnSendError(t),
+		expect.WithDefaultTimeout(time.Second),
+	}
+
+	console, err := expect.NewConsole(consoleOpts...)
+	require.NoError(t, err)
+	t.Cleanup(func() { testCloser(t, console) })
+
+	return console
+}
+
+func newTestIOStreams(t *testing.T, console *expect.Console, spinnerDisabled bool) *IOStreams {
+	t.Helper()
+
+	in := console.Tty()
+	out := console.Tty()
+	errOut := console.Tty()
+
+	// Because the briandowns/spinner checks os.Stdout directly,
+	// we need this hack to trick it into allowing the spinner to print...
+	os.Stdout = out
+
+	io := &IOStreams{
+		In:     in,
+		Out:    out,
+		ErrOut: errOut,
+		term:   fakeTerm{},
+	}
+	io.progressIndicatorEnabled = true
+	io.SetSpinnerDisabled(spinnerDisabled)
+	return io
+}
+
+// failOnExpectError adds an observer that will fail the test in a standardised way
+// if any expectation on the command output fails, without requiring an explicit
+// assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnExpectError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithExpectObserver(
+		func(matchers []expect.Matcher, buf string, err error) {
+			t.Helper()
+
+			if err == nil {
+				return
+			}
+
+			if len(matchers) == 0 {
+				t.Fatalf("Error occurred while matching %q: %s\n", buf, err)
+			}
+
+			var criteria []string
+			for _, matcher := range matchers {
+				criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
+			}
+			t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err)
+		},
+	)
+}
+
+// failOnSendError adds an observer that will fail the test in a standardised way
+// if any sending of input fails, without requiring an explicit assertion.
+//
+// Use WithRelaxedIO to disable this behaviour.
+func failOnSendError(t *testing.T) expect.ConsoleOpt {
+	t.Helper()
+	return expect.WithSendObserver(
+		func(msg string, n int, err error) {
+			t.Helper()
+
+			if err != nil {
+				t.Fatalf("Failed to send %q: %s\n", msg, err)
+			}
+			if len(msg) != n {
+				t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg)
+			}
+		},
+	)
+}
+
+// testCloser is a helper to fail the test if a Closer fails to close.
+func testCloser(t *testing.T, closer io.Closer) {
+	t.Helper()
+	if err := closer.Close(); err != nil {
+		t.Errorf("Close failed: %s", err)
+	}
+}
diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go
index 8642feed0..e893c9a3b 100644
--- a/pkg/search/searcher_test.go
+++ b/pkg/search/searcher_test.go
@@ -1,8 +1,10 @@
 package search
 
 import (
+	"fmt"
 	"net/http"
 	"net/url"
+	"strconv"
 	"testing"
 
 	"github.com/MakeNowJust/heredoc"
@@ -46,10 +48,14 @@ func TestSearcherCode(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/code", values),
-					httpmock.JSONResponse(CodeResult{
-						IncompleteResults: false,
-						Items:             []Code{{Name: "file.go"}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"name": "file.go",
+							},
+						},
 					}),
 				)
 			},
@@ -66,10 +72,14 @@ func TestSearcherCode(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "api/v3/search/code", values),
-					httpmock.JSONResponse(CodeResult{
-						IncompleteResults: false,
-						Items:             []Code{{Name: "file.go"}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"name": "file.go",
+							},
+						},
 					}),
 				)
 			},
@@ -84,25 +94,83 @@ func TestSearcherCode(t *testing.T) {
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				firstReq := httpmock.QueryMatcher("GET", "search/code", values)
-				firstRes := httpmock.JSONResponse(CodeResult{
-					IncompleteResults: false,
-					Items:             []Code{{Name: "file.go"}},
-					Total:             2,
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"name": "file.go",
+						},
+					},
+				})
+				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/code?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
+				secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
+					"page":     []string{"2"},
+					"per_page": []string{"30"},
+					"q":        []string{"keyword language:go"},
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"name": "file2.go",
+						},
+					},
+				})
+				reg.Register(firstReq, firstRes)
+				reg.Register(secondReq, secondRes)
+			},
+		},
+		{
+			name: "collect full and partial pages under total number of matching search results",
+			query: Query{
+				Keywords: []string{"keyword"},
+				Kind:     "code",
+				Limit:    110,
+				Qualifiers: Qualifiers{
+					Language: "go",
 				},
-				)
+			},
+			result: CodeResult{
+				IncompleteResults: false,
+				Items: initialize(0, 110, func(i int) Code {
+					return Code{
+						Name: fmt.Sprintf("name%d.go", i),
+					}
+				}),
+				Total: 287,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
+					"page":     []string{"1"},
+					"per_page": []string{"100"},
+					"q":        []string{"keyword language:go"},
+				})
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(0, 100, func(i int) interface{} {
+						return map[string]interface{}{
+							"name": fmt.Sprintf("name%d.go", i),
+						}
+					}),
+				})
 				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/code?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
 				secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
 					"page":     []string{"2"},
-					"per_page": []string{"29"},
+					"per_page": []string{"100"},
 					"q":        []string{"keyword language:go"},
-				},
-				)
-				secondRes := httpmock.JSONResponse(CodeResult{
-					IncompleteResults: false,
-					Items:             []Code{{Name: "file2.go"}},
-					Total:             2,
-				},
-				)
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(100, 200, func(i int) interface{} {
+						return map[string]interface{}{
+							"name": fmt.Sprintf("name%d.go", i),
+						}
+					}),
+				})
 				reg.Register(firstReq, firstRes)
 				reg.Register(secondReq, secondRes)
 			},
@@ -201,10 +269,14 @@ func TestSearcherCommits(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/commits", values),
-					httpmock.JSONResponse(CommitsResult{
-						IncompleteResults: false,
-						Items:             []Commit{{Sha: "abc"}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"sha": "abc",
+							},
+						},
 					}),
 				)
 			},
@@ -221,10 +293,14 @@ func TestSearcherCommits(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "api/v3/search/commits", values),
-					httpmock.JSONResponse(CommitsResult{
-						IncompleteResults: false,
-						Items:             []Commit{{Sha: "abc"}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"sha": "abc",
+							},
+						},
 					}),
 				)
 			},
@@ -239,27 +315,92 @@ func TestSearcherCommits(t *testing.T) {
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				firstReq := httpmock.QueryMatcher("GET", "search/commits", values)
-				firstRes := httpmock.JSONResponse(CommitsResult{
-					IncompleteResults: false,
-					Items:             []Commit{{Sha: "abc"}},
-					Total:             2,
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"sha": "abc",
+						},
+					},
+				})
+				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
+				secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
+					"page":     []string{"2"},
+					"per_page": []string{"30"},
+					"order":    []string{"desc"},
+					"sort":     []string{"committer-date"},
+					"q":        []string{"keyword author:foobar committer-date:>2021-02-28"},
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"sha": "def",
+						},
+					},
+				})
+				reg.Register(firstReq, firstRes)
+				reg.Register(secondReq, secondRes)
+			},
+		},
+		{
+			name: "collect full and partial pages under total number of matching search results",
+			query: Query{
+				Keywords: []string{"keyword"},
+				Kind:     "commits",
+				Limit:    110,
+				Order:    "desc",
+				Sort:     "committer-date",
+				Qualifiers: Qualifiers{
+					Author:        "foobar",
+					CommitterDate: ">2021-02-28",
 				},
-				)
+			},
+			result: CommitsResult{
+				IncompleteResults: false,
+				Items: initialize(0, 110, func(i int) Commit {
+					return Commit{
+						Sha: strconv.Itoa(i),
+					}
+				}),
+				Total: 287,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
+					"page":     []string{"1"},
+					"per_page": []string{"100"},
+					"order":    []string{"desc"},
+					"sort":     []string{"committer-date"},
+					"q":        []string{"keyword author:foobar committer-date:>2021-02-28"},
+				})
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(0, 100, func(i int) map[string]interface{} {
+						return map[string]interface{}{
+							"sha": strconv.Itoa(i),
+						}
+					}),
+				})
 				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
 				secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
 					"page":     []string{"2"},
-					"per_page": []string{"29"},
+					"per_page": []string{"100"},
 					"order":    []string{"desc"},
 					"sort":     []string{"committer-date"},
 					"q":        []string{"keyword author:foobar committer-date:>2021-02-28"},
-				},
-				)
-				secondRes := httpmock.JSONResponse(CommitsResult{
-					IncompleteResults: false,
-					Items:             []Commit{{Sha: "def"}},
-					Total:             2,
-				},
-				)
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(100, 200, func(i int) map[string]interface{} {
+						return map[string]interface{}{
+							"sha": strconv.Itoa(i),
+						}
+					}),
+				})
 				reg.Register(firstReq, firstRes)
 				reg.Register(secondReq, secondRes)
 			},
@@ -269,8 +410,8 @@ func TestSearcherCommits(t *testing.T) {
 			query:   query,
 			wantErr: true,
 			errMsg: heredoc.Doc(`
-        Invalid search query "keyword author:foobar committer-date:>2021-02-28".
-        "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
+				Invalid search query "keyword author:foobar committer-date:>2021-02-28".
+				"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/commits", values),
@@ -413,15 +554,14 @@ func TestSearcherRepositories(t *testing.T) {
 						},
 					},
 				})
-				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
+				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
 				secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
 					"page":     []string{"2"},
-					"per_page": []string{"29"},
+					"per_page": []string{"30"},
 					"order":    []string{"desc"},
 					"sort":     []string{"stars"},
 					"q":        []string{"keyword stars:>=5 topic:topic"},
-				},
-				)
+				})
 				secondRes := httpmock.JSONResponse(map[string]interface{}{
 					"incomplete_results": false,
 					"total_count":        2,
@@ -435,13 +575,73 @@ func TestSearcherRepositories(t *testing.T) {
 				reg.Register(secondReq, secondRes)
 			},
 		},
+		{
+			name: "collect full and partial pages under total number of matching search results",
+			query: Query{
+				Keywords: []string{"keyword"},
+				Kind:     "repositories",
+				Limit:    110,
+				Order:    "desc",
+				Sort:     "stars",
+				Qualifiers: Qualifiers{
+					Stars: ">=5",
+					Topic: []string{"topic"},
+				},
+			},
+			result: RepositoriesResult{
+				IncompleteResults: false,
+				Items: initialize(0, 110, func(i int) Repository {
+					return Repository{
+						Name: fmt.Sprintf("name%d", i),
+					}
+				}),
+				Total: 287,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
+					"page":     []string{"1"},
+					"per_page": []string{"100"},
+					"order":    []string{"desc"},
+					"sort":     []string{"stars"},
+					"q":        []string{"keyword stars:>=5 topic:topic"},
+				})
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(0, 100, func(i int) interface{} {
+						return map[string]interface{}{
+							"name": fmt.Sprintf("name%d", i),
+						}
+					}),
+				})
+				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
+				secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
+					"page":     []string{"2"},
+					"per_page": []string{"100"},
+					"order":    []string{"desc"},
+					"sort":     []string{"stars"},
+					"q":        []string{"keyword stars:>=5 topic:topic"},
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(100, 200, func(i int) interface{} {
+						return map[string]interface{}{
+							"name": fmt.Sprintf("name%d", i),
+						}
+					}),
+				})
+				reg.Register(firstReq, firstRes)
+				reg.Register(secondReq, secondRes)
+			},
+		},
 		{
 			name:    "handles search errors",
 			query:   query,
 			wantErr: true,
 			errMsg: heredoc.Doc(`
-        Invalid search query "keyword stars:>=5 topic:topic".
-        "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
+				Invalid search query "keyword stars:>=5 topic:topic".
+				"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/repositories", values),
@@ -529,10 +729,14 @@ func TestSearcherIssues(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/issues", values),
-					httpmock.JSONResponse(IssuesResult{
-						IncompleteResults: false,
-						Items:             []Issue{{Number: 1234}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"number": 1234,
+							},
+						},
 					}),
 				)
 			},
@@ -549,10 +753,14 @@ func TestSearcherIssues(t *testing.T) {
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "api/v3/search/issues", values),
-					httpmock.JSONResponse(IssuesResult{
-						IncompleteResults: false,
-						Items:             []Issue{{Number: 1234}},
-						Total:             1,
+					httpmock.JSONResponse(map[string]interface{}{
+						"incomplete_results": false,
+						"total_count":        1,
+						"items": []interface{}{
+							map[string]interface{}{
+								"number": 1234,
+							},
+						},
 					}),
 				)
 			},
@@ -567,27 +775,92 @@ func TestSearcherIssues(t *testing.T) {
 			},
 			httpStubs: func(reg *httpmock.Registry) {
 				firstReq := httpmock.QueryMatcher("GET", "search/issues", values)
-				firstRes := httpmock.JSONResponse(IssuesResult{
-					IncompleteResults: false,
-					Items:             []Issue{{Number: 1234}},
-					Total:             2,
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"number": 1234,
+						},
+					},
+				})
+				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
+				secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
+					"page":     []string{"2"},
+					"per_page": []string{"30"},
+					"order":    []string{"desc"},
+					"sort":     []string{"comments"},
+					"q":        []string{"keyword is:locked is:public language:go"},
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        2,
+					"items": []interface{}{
+						map[string]interface{}{
+							"number": 5678,
+						},
+					},
+				})
+				reg.Register(firstReq, firstRes)
+				reg.Register(secondReq, secondRes)
+			},
+		},
+		{
+			name: "collect full and partial pages under total number of matching search results",
+			query: Query{
+				Keywords: []string{"keyword"},
+				Kind:     "issues",
+				Limit:    110,
+				Order:    "desc",
+				Sort:     "comments",
+				Qualifiers: Qualifiers{
+					Language: "go",
+					Is:       []string{"public", "locked"},
 				},
-				)
+			},
+			result: IssuesResult{
+				IncompleteResults: false,
+				Items: initialize(0, 110, func(i int) Issue {
+					return Issue{
+						Number: i,
+					}
+				}),
+				Total: 287,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
+					"page":     []string{"1"},
+					"per_page": []string{"100"},
+					"order":    []string{"desc"},
+					"sort":     []string{"comments"},
+					"q":        []string{"keyword is:locked is:public language:go"},
+				})
+				firstRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(0, 100, func(i int) interface{} {
+						return map[string]interface{}{
+							"number": i,
+						}
+					}),
+				})
 				firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
 				secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
 					"page":     []string{"2"},
-					"per_page": []string{"29"},
+					"per_page": []string{"100"},
 					"order":    []string{"desc"},
 					"sort":     []string{"comments"},
 					"q":        []string{"keyword is:locked is:public language:go"},
-				},
-				)
-				secondRes := httpmock.JSONResponse(IssuesResult{
-					IncompleteResults: false,
-					Items:             []Issue{{Number: 5678}},
-					Total:             2,
-				},
-				)
+				})
+				secondRes := httpmock.JSONResponse(map[string]interface{}{
+					"incomplete_results": false,
+					"total_count":        287,
+					"items": initialize(100, 200, func(i int) interface{} {
+						return map[string]interface{}{
+							"number": i,
+						}
+					}),
+				})
 				reg.Register(firstReq, firstRes)
 				reg.Register(secondReq, secondRes)
 			},
@@ -597,8 +870,8 @@ func TestSearcherIssues(t *testing.T) {
 			query:   query,
 			wantErr: true,
 			errMsg: heredoc.Doc(`
-        Invalid search query "keyword is:locked is:public language:go".
-        "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
+				Invalid search query "keyword is:locked is:public language:go".
+				"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
 			httpStubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.QueryMatcher("GET", "search/issues", values),
@@ -686,3 +959,12 @@ func TestSearcherURL(t *testing.T) {
 		})
 	}
 }
+
+// initialize generate slices over a range for test scenarios using the provided initializer.
+func initialize[T any](start int, stop int, initializer func(i int) T) []T {
+	results := make([]T, 0, (stop - start))
+	for i := start; i < stop; i++ {
+		results = append(results, initializer(i))
+	}
+	return results
+}