-
Notifications
You must be signed in to change notification settings - Fork 8k
Improve repository base and head resolution #1706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
969321b
d534a94
7a8db80
f923966
f99a554
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,33 +1,27 @@ | ||
| // TODO: rename this package to avoid clash with stdlib | ||
| package context | ||
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/AlecAivazis/survey/v2" | ||
| "github.com/cli/cli/api" | ||
| "github.com/cli/cli/internal/config" | ||
| "github.com/cli/cli/git" | ||
| "github.com/cli/cli/internal/ghrepo" | ||
| "github.com/cli/cli/pkg/iostreams" | ||
| "github.com/cli/cli/pkg/prompt" | ||
| ) | ||
|
|
||
| // Context represents the interface for querying information about the current environment | ||
| type Context interface { | ||
| Config() (config.Config, error) | ||
| } | ||
|
|
||
| // cap the number of git remotes looked up, since the user might have an | ||
| // unusually large number of git remotes | ||
| const maxRemotesForLookup = 5 | ||
|
|
||
| // ResolveRemotesToRepos takes in a list of git remotes and fetches more information about the repositories they map to. | ||
| // Only the git remotes belonging to the same hostname are ever looked up; all others are ignored. | ||
| func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (ResolvedRemotes, error) { | ||
| func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (*ResolvedRemotes, error) { | ||
| sort.Stable(remotes) | ||
|
|
||
| result := ResolvedRemotes{ | ||
| Remotes: remotes, | ||
| result := &ResolvedRemotes{ | ||
| remotes: remotes, | ||
| apiClient: client, | ||
| } | ||
|
|
||
|
|
@@ -38,138 +32,136 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re | |
| if err != nil { | ||
| return result, err | ||
| } | ||
| result.BaseOverride = baseOverride | ||
| result.baseOverride = baseOverride | ||
| } | ||
|
|
||
| foundBaseOverride := false | ||
| var hostname string | ||
| return result, nil | ||
| } | ||
|
|
||
| func resolveNetwork(result *ResolvedRemotes) error { | ||
| var repos []ghrepo.Interface | ||
| for i, r := range remotes { | ||
| if i == 0 { | ||
| hostname = r.RepoHost() | ||
| } else if !strings.EqualFold(r.RepoHost(), hostname) { | ||
| // ignore all remotes for a hostname different to that of the 1st remote | ||
| continue | ||
| } | ||
| for _, r := range result.remotes { | ||
| repos = append(repos, r) | ||
| if baseOverride != nil && ghrepo.IsSame(r, baseOverride) { | ||
| foundBaseOverride = true | ||
| } | ||
| if len(repos) == maxRemotesForLookup { | ||
| break | ||
| } | ||
| } | ||
| if baseOverride != nil && !foundBaseOverride { | ||
| // additionally, look up the explicitly specified base repo if it's not | ||
| // already covered by git remotes | ||
| repos = append(repos, baseOverride) | ||
| } | ||
|
|
||
| networkResult, err := api.RepoNetwork(client, repos) | ||
| if err != nil { | ||
| return result, err | ||
| } | ||
| result.Network = networkResult | ||
| return result, nil | ||
| networkResult, err := api.RepoNetwork(result.apiClient, repos) | ||
| result.network = &networkResult | ||
| return err | ||
| } | ||
|
|
||
| type ResolvedRemotes struct { | ||
| BaseOverride ghrepo.Interface | ||
| Remotes Remotes | ||
| Network api.RepoNetworkResult | ||
| baseOverride ghrepo.Interface | ||
| remotes Remotes | ||
| network *api.RepoNetworkResult | ||
| apiClient *api.Client | ||
| } | ||
|
|
||
| // BaseRepo is the first found repository in the "upstream", "github", "origin" | ||
| // git remote order, resolved to the parent repo if the git remote points to a fork | ||
| func (r ResolvedRemotes) BaseRepo() (*api.Repository, error) { | ||
| if r.BaseOverride != nil { | ||
| for _, repo := range r.Network.Repositories { | ||
| if repo != nil && ghrepo.IsSame(repo, r.BaseOverride) { | ||
| return repo, nil | ||
| func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, error) { | ||
| if r.baseOverride != nil { | ||
| return r.baseOverride, nil | ||
| } | ||
|
|
||
| // if any of the remotes already has a resolution, respect that | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally, it seems that there should only be one resolved remote for any repo, but do we need any safeguards/messaging in place for the case when there are more than one?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possibly we should sort the remotes list so that if there are multiple resolved remotes we will deterministically select the same one every time.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @samcoe Good catch! The remote list is already guaranteed to be sorted: first Good question about what if multiple remotes have the resolution bit. Right now I would say that this is not possible, since the resolution bit is only written when no git remote already has a bit. |
||
| for _, r := range r.remotes { | ||
| if r.Resolved == "base" { | ||
| return r, nil | ||
| } else if r.Resolved != "" { | ||
| repo, err := ghrepo.FromFullName(r.Resolved) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return ghrepo.NewWithHost(repo.RepoOwner(), repo.RepoName(), r.RepoHost()), nil | ||
| } | ||
| } | ||
|
|
||
| if !io.CanPrompt() { | ||
| // we cannot prompt, so just resort to the 1st remote | ||
| return r.remotes[0], nil | ||
| } | ||
|
|
||
| // from here on, consult the API | ||
| if r.network == nil { | ||
| err := resolveNetwork(r) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
| var repoNames []string | ||
| repoMap := map[string]*api.Repository{} | ||
| add := func(r *api.Repository) { | ||
| fn := ghrepo.FullName(r) | ||
| if _, ok := repoMap[fn]; !ok { | ||
| repoMap[fn] = r | ||
| repoNames = append(repoNames, fn) | ||
| } | ||
| return nil, fmt.Errorf("failed looking up information about the '%s' repository", | ||
| ghrepo.FullName(r.BaseOverride)) | ||
| } | ||
|
|
||
| for _, repo := range r.Network.Repositories { | ||
| for _, repo := range r.network.Repositories { | ||
| if repo == nil { | ||
| continue | ||
| } | ||
| if repo.IsFork() { | ||
| return repo.Parent, nil | ||
| add(repo.Parent) | ||
| } | ||
| return repo, nil | ||
| add(repo) | ||
| } | ||
|
|
||
| return nil, errors.New("not found") | ||
| } | ||
|
|
||
| // HeadRepo is a fork of base repo (if any), or the first found repository that | ||
| // has push access | ||
| func (r ResolvedRemotes) HeadRepo() (*api.Repository, error) { | ||
| baseRepo, err := r.BaseRepo() | ||
| if err != nil { | ||
| return nil, err | ||
| if len(repoNames) == 0 { | ||
| return r.remotes[0], nil | ||
| } | ||
|
|
||
| // try to find a pushable fork among existing remotes | ||
| for _, repo := range r.Network.Repositories { | ||
| if repo != nil && repo.Parent != nil && repo.ViewerCanPush() && ghrepo.IsSame(repo.Parent, baseRepo) { | ||
| return repo, nil | ||
| baseName := repoNames[0] | ||
| if len(repoNames) > 1 { | ||
| err := prompt.SurveyAskOne(&survey.Select{ | ||
| Message: "Which should be the base repository (used for e.g. querying issues) for this directory?", | ||
| Options: repoNames, | ||
| }, &baseName) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
| // a fork might still exist on GitHub, so let's query for it | ||
| var notFound *api.NotFoundError | ||
| if repo, err := api.RepoFindFork(r.apiClient, baseRepo); err == nil { | ||
| return repo, nil | ||
| } else if !errors.As(err, ¬Found) { | ||
| return nil, err | ||
| // determine corresponding git remote | ||
| selectedRepo := repoMap[baseName] | ||
| resolution := "base" | ||
| remote, _ := r.RemoteForRepo(selectedRepo) | ||
| if remote == nil { | ||
| remote = r.remotes[0] | ||
| resolution = ghrepo.FullName(selectedRepo) | ||
| } | ||
|
|
||
| // cache the result to git config | ||
| err := git.SetRemoteResolution(remote.Name, resolution) | ||
| return selectedRepo, err | ||
| } | ||
|
|
||
| func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) { | ||
| if r.network == nil { | ||
| err := resolveNetwork(r) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
| // fall back to any listed repository that has push access | ||
| for _, repo := range r.Network.Repositories { | ||
| var results []*api.Repository | ||
| for _, repo := range r.network.Repositories { | ||
| if repo != nil && repo.ViewerCanPush() { | ||
| return repo, nil | ||
| results = append(results, repo) | ||
| } | ||
| } | ||
| return nil, errors.New("none of the repositories have push access") | ||
| return results, nil | ||
| } | ||
|
|
||
| // RemoteForRepo finds the git remote that points to a repository | ||
| func (r ResolvedRemotes) RemoteForRepo(repo ghrepo.Interface) (*Remote, error) { | ||
| for i, remote := range r.Remotes { | ||
| if ghrepo.IsSame(remote, repo) || | ||
| // additionally, look up the resolved repository name in case this | ||
| // git remote points to this repository via a redirect | ||
| (r.Network.Repositories[i] != nil && ghrepo.IsSame(r.Network.Repositories[i], repo)) { | ||
| func (r *ResolvedRemotes) RemoteForRepo(repo ghrepo.Interface) (*Remote, error) { | ||
| for _, remote := range r.remotes { | ||
| if ghrepo.IsSame(remote, repo) { | ||
| return remote, nil | ||
| } | ||
| } | ||
| return nil, errors.New("not found") | ||
| } | ||
|
|
||
| // New initializes a Context that reads from the filesystem | ||
| func New() Context { | ||
| return &fsContext{} | ||
| } | ||
|
|
||
| // A Context implementation that queries the filesystem | ||
| type fsContext struct { | ||
| config config.Config | ||
| } | ||
|
|
||
| func (c *fsContext) Config() (config.Config, error) { | ||
| if c.config == nil { | ||
| cfg, err := config.ParseDefaultConfig() | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| cfg = config.NewBlankConfig() | ||
| } else if err != nil { | ||
| return nil, err | ||
| } | ||
| c.config = cfg | ||
| } | ||
| return c.config, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are your thoughts on changing this method to take in
canPrompt boolargument instead of theio *iostreams.IOStreamsargument? It seems that is all we are doing withiohere, and might make the method easier to test.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(fwiw, iostreams is easy to test)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samcoe That's an excellent point! It's usually better to pass primitives (such as boolean) than complex types such as IOStreams. Here, however, passing in IOStreams semantically draws attention that there will be prompt functionality inside. Right now, the Survey library isn't configured with IOStreams, but we are hoping to one day hook it up. After that, any part of functionality that needs access to stdin/stdout/stderr should take an IOStreams so it's easier to stub out in tests.