diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar new file mode 100644 index 00000000000..ca58918a911 --- /dev/null +++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar @@ -0,0 +1,39 @@ +# 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 + +# 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} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a fork in the same org +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} + +# 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 + +# Clone the fork +exec gh repo clone ${ORG}/${FORK} +cd ${FORK} + +# Prepare a branch +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, gh pr create does not support headRepositoryId needed for private forks +exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }' + +# View the PR +exec gh pr view +stdout 'Feature Title' diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 4bb79ac8b64..e4a70eb2230 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -246,27 +246,36 @@ func (f *finder) parseCurrentBranch() (string, int, error) { return "", prNumber, nil } - var branchOwner string + var gitRemoteRepo ghrepo.Interface if branchConfig.RemoteURL != nil { // the branch merges from a remote specified by URL if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - branchOwner = r.RepoOwner() + gitRemoteRepo = r } } else if branchConfig.RemoteName != "" { // the branch merges from a remote specified by name rem, _ := f.remotesFn() if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { - branchOwner = r.RepoOwner() + gitRemoteRepo = r } } - if branchOwner != "" { + if gitRemoteRepo != nil { if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") } // prepend `OWNER:` if this branch is pushed to a fork - if !strings.EqualFold(branchOwner, f.repo.RepoOwner()) { - prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) + // This is determined by: + // - The repo having a different owner + // - The repo having the same owner but a different name (private org fork) + // I suspect that the implementation of the second case may be broken in the face + // of a repo rename, where the remote hasn't been updated locally. This is a + // frequent issue in commands that use SmartBaseRepoFunc. It's not any worse than not + // supporting this case at all though. + sameOwner := strings.EqualFold(gitRemoteRepo.RepoOwner(), f.repo.RepoOwner()) + sameOwnerDifferentRepoName := sameOwner && !strings.EqualFold(gitRemoteRepo.RepoName(), f.repo.RepoName()) + if !sameOwner || sameOwnerDifferentRepoName { + prHeadRef = fmt.Sprintf("%s:%s", gitRemoteRepo.RepoOwner(), prHeadRef) } } @@ -355,7 +364,13 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h }) for _, pr := range prs { - if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) && (pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranch) { + headBranchMatches := pr.HeadLabel() == headBranch + 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 != headBranch + + if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault { return &pr, nil } } diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 6df8d300048..258ef01e829 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -347,7 +347,7 @@ func TestFind(t *testing.T) { wantRepo: "https://github.com/OWNER/REPO", }, { - name: "current branch with upstream configuration", + name: "current branch with upstream RemoteURL configuration", args: args{ selector: "", fields: []string{"id", "number"}, @@ -384,6 +384,47 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/OWNER/REPO", }, + { + name: "current branch with upstream and fork in same org", + args: args{ + selector: "", + fields: []string{"id", "number"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + c.RemoteName = "origin" + return + }, + remotesFn: func() (context.Remotes, error) { + return context.Remotes{{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO-FORK"), + }}, nil + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequests":{"nodes":[ + { + "number": 13, + "state": "OPEN", + "baseRefName": "main", + "headRefName": "blueberries", + "isCrossRepository": true, + "headRepositoryOwner": {"login":"OWNER"} + } + ]} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, { name: "current branch made by pr checkout", args: args{