394 lines
14 KiB
Go
394 lines
14 KiB
Go
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),
|
|
}
|
|
}
|
|
|
|
// ResolvePullRequestRefs 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
|
|
}
|