Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions api/queries_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
name
owner { login }
hasIssuesEnabled
description
viewerPermission
Expand Down Expand Up @@ -317,8 +319,8 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
}, nil
}

// RepoFindFork finds a fork of repo affiliated with the viewer
func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
// RepoFindForks finds forks of the repo that are affiliated with the viewer
func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) {
result := struct {
Repository struct {
Forks struct {
Expand All @@ -330,12 +332,13 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"limit": limit,
}

if err := client.GraphQL(repo.RepoHost(), `
query RepositoryFindFork($owner: String!, $repo: String!) {
query RepositoryFindFork($owner: String!, $repo: String!, $limit: Int!) {
repository(owner: $owner, name: $repo) {
forks(first: 1, affiliations: [OWNER, COLLABORATOR]) {
forks(first: $limit, affiliations: [OWNER, COLLABORATOR]) {
nodes {
id
name
Expand All @@ -350,14 +353,18 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
return nil, err
}

forks := result.Repository.Forks.Nodes
// we check ViewerCanPush, even though we expect it to always be true per
// `affiliations` condition, to guard against versions of GitHub with a
// faulty `affiliations` implementation
if len(forks) > 0 && forks[0].ViewerCanPush() {
return InitRepoHostname(&forks[0], repo.RepoHost()), nil
var results []*Repository
for _, r := range result.Repository.Forks.Nodes {
// we check ViewerCanPush, even though we expect it to always be true per
// `affiliations` condition, to guard against versions of GitHub with a
// faulty `affiliations` implementation
if !r.ViewerCanPush() {
continue
}
results = append(results, InitRepoHostname(&r, repo.RepoHost()))
}
return nil, &NotFoundError{errors.New("no fork found")}

return results, nil
}

type RepoMetadataResult struct {
Expand Down
24 changes: 0 additions & 24 deletions context/blank_context.go

This file was deleted.

202 changes: 97 additions & 105 deletions context/context.go
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,
}

Expand All @@ -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) {
Copy link
Contributor

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 bool argument instead of the io *iostreams.IOStreams argument? It seems that is all we are doing with io here, and might make the method easier to test.

Copy link
Contributor

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)

Copy link
Contributor Author

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.

if r.baseOverride != nil {
return r.baseOverride, nil
}

// if any of the remotes already has a resolution, respect that
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 upstream, then github, then origin, then alphabetical order. This makes me feel confident that the resolution will be deterministic.

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, &notFound) {
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
}
Loading