diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 79e150113..6f9f6547a 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -33,7 +33,7 @@ jobs: repository: github/cli.github.com path: site fetch-depth: 0 - token: ${{secrets.SITE_GITHUB_TOKEN}} + ssh-key: ${{secrets.SITE_SSH_KEY}} - name: Update site man pages env: GIT_COMMITTER_NAME: cli automation diff --git a/api/queries_issue.go b/api/queries_issue.go index d035e6ad8..d09497569 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -16,8 +16,9 @@ type IssuesPayload struct { } type IssuesAndTotalCount struct { - Issues []Issue - TotalCount int + Issues []Issue + TotalCount int + SearchCapped bool } type Issue struct { diff --git a/api/queries_pr.go b/api/queries_pr.go index 1e060b1da..388446f42 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -26,6 +26,7 @@ type PullRequestsPayload struct { type PullRequestAndTotalCount struct { TotalCount int PullRequests []PullRequest + SearchCapped bool } type PullRequest struct { diff --git a/docs/project-layout.md b/docs/project-layout.md index 4bbdbad40..60d0c2aac 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -24,7 +24,7 @@ commands is: pkg/cmd///.go ``` Following the above example, the main implementation for the `gh issue list` command, including its help -text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go) +text, is in [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go) Other help topics not specific to any command, for example `gh help environment`, are found in [pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go). diff --git a/git/url.go b/git/url.go index 7d4aac4d7..1a3e97fd6 100644 --- a/git/url.go +++ b/git/url.go @@ -14,6 +14,7 @@ func isSupportedProtocol(u string) bool { strings.HasPrefix(u, "git+ssh:") || strings.HasPrefix(u, "git:") || strings.HasPrefix(u, "http:") || + strings.HasPrefix(u, "git+https:") || strings.HasPrefix(u, "https:") } @@ -43,6 +44,10 @@ func ParseURL(rawURL string) (u *url.URL, err error) { u.Scheme = "ssh" } + if u.Scheme == "git+https" { + u.Scheme = "https" + } + if u.Scheme != "ssh" { return } diff --git a/git/url_test.go b/git/url_test.go index 679739b41..f5b3b50d0 100644 --- a/git/url_test.go +++ b/git/url_test.go @@ -28,11 +28,26 @@ func TestIsURL(t *testing.T) { url: "git://example.com/owner/repo", want: true, }, + { + name: "git with extension", + url: "git://example.com/owner/repo.git", + want: true, + }, + { + name: "git+ssh", + url: "git+ssh://git@example.com/owner/repo.git", + want: true, + }, { name: "https", url: "https://example.com/owner/repo.git", want: true, }, + { + name: "git+https", + url: "git+https://example.com/owner/repo.git", + want: true, + }, { name: "no protocol", url: "example.com/owner/repo", @@ -121,6 +136,16 @@ func TestParseURL(t *testing.T) { Path: "/owner/repo.git", }, }, + { + name: "git+https", + url: "git+https://example.com/owner/repo.git", + want: url{ + Scheme: "https", + User: "", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, { name: "scp-like", url: "git@example.com:owner/repo.git", diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index fdb500cbf..71c75267f 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" "github.com/spf13/cobra" ) @@ -112,86 +113,101 @@ func runBrowse(opts *BrowseOptions) error { return fmt.Errorf("unable to determine base repository: %w", err) } - httpClient, err := opts.HttpClient() + section, err := parseSection(baseRepo, opts) if err != nil { - return fmt.Errorf("unable to create an http client: %w", err) - } - url := ghrepo.GenerateRepoURL(baseRepo, "") - - if opts.SelectorArg == "" { - if opts.ProjectsFlag { - url += "/projects" - } - if opts.SettingsFlag { - url += "/settings" - } - if opts.WikiFlag { - url += "/wiki" - } - if opts.Branch != "" { - url += "/tree/" + opts.Branch + "/" - } - } else { - if isNumber(opts.SelectorArg) { - url += "/issues/" + opts.SelectorArg - } else { - fileArg, err := parseFileArg(opts.SelectorArg) - if err != nil { - return err - } - if opts.Branch != "" { - url += "/tree/" + opts.Branch + "/" - } else { - apiClient := api.NewClientFromHTTP(httpClient) - branchName, err := api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - url += "/tree/" + branchName + "/" - } - url += fileArg - } + return err } + url := ghrepo.GenerateRepoURL(baseRepo, section) if opts.NoBrowserFlag { - fmt.Fprintf(opts.IO.Out, "%s\n", url) - return nil - } else { - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "now opening %s in browser\n", url) - } - return opts.Browser.Browse(url) + _, err := fmt.Fprintln(opts.IO.Out, url) + return err } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + return opts.Browser.Browse(url) } -func parseFileArg(fileArg string) (string, error) { - arr := strings.Split(fileArg, ":") - if len(arr) > 2 { - return "", fmt.Errorf("invalid use of colon\nUse 'gh browse --help' for more information about browse\n") +func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) { + if opts.SelectorArg == "" { + if opts.ProjectsFlag { + return "projects", nil + } else if opts.SettingsFlag { + return "settings", nil + } else if opts.WikiFlag { + return "wiki", nil + } else if opts.Branch == "" { + return "", nil + } } - if len(arr) > 1 { - out := arr[0] + "#L" - lineRange := strings.Split(arr[1], "-") - - if len(lineRange) > 0 { - if !isNumber(lineRange[0]) { - return "", fmt.Errorf("invalid line number after colon\nUse 'gh browse --help' for more information about browse\n") - } - out += lineRange[0] - } - - if len(lineRange) > 1 { - if !isNumber(lineRange[1]) { - return "", fmt.Errorf("invalid line range after colon\nUse 'gh browse --help' for more information about browse\n") - } - out += "-L" + lineRange[1] - } - - return out, nil + if isNumber(opts.SelectorArg) { + return fmt.Sprintf("issues/%s", opts.SelectorArg), nil } - return arr[0], nil + filePath, rangeStart, rangeEnd, err := parseFile(opts.SelectorArg) + if err != nil { + return "", err + } + + branchName := opts.Branch + if branchName == "" { + httpClient, err := opts.HttpClient() + if err != nil { + return "", err + } + apiClient := api.NewClientFromHTTP(httpClient) + branchName, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return "", fmt.Errorf("error determining the default branch: %w", err) + } + } + + if rangeStart > 0 { + var rangeFragment string + if rangeEnd > 0 && rangeStart != rangeEnd { + rangeFragment = fmt.Sprintf("L%d-L%d", rangeStart, rangeEnd) + } else { + rangeFragment = fmt.Sprintf("L%d", rangeStart) + } + return fmt.Sprintf("blob/%s/%s?plain=1#%s", branchName, filePath, rangeFragment), nil + } + return fmt.Sprintf("tree/%s/%s", branchName, filePath), nil +} + +func parseFile(f string) (p string, start int, end int, err error) { + parts := strings.SplitN(f, ":", 3) + if len(parts) > 2 { + err = fmt.Errorf("invalid file argument: %q", f) + return + } + + p = parts[0] + if len(parts) < 2 { + return + } + + if idx := strings.IndexRune(parts[1], '-'); idx >= 0 { + start, err = strconv.Atoi(parts[1][:idx]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + return + } + end, err = strconv.Atoi(parts[1][idx+1:]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + } + return + } + + start, err = strconv.Atoi(parts[1]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + } + end = start + return } func isNumber(arg string) bool { diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index bf3349c3c..0cb4df786 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -213,7 +213,7 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", - expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32", + expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32", }, { name: "file with line range", @@ -222,12 +222,37 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", - expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32-L40", + expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32-L40", + }, + { + name: "invalid default branch", + opts: BrowseOptions{ + SelectorArg: "chocolate-pecan-pie.txt", + }, + baseRepo: ghrepo.New("andrewhsu", "recipies"), + defaultBranch: "", + wantsErr: true, + }, + { + name: "file with invalid line number after colon", + opts: BrowseOptions{ + SelectorArg: "laptime-notes.txt:w-9", + }, + baseRepo: ghrepo.New("andrewhsu", "sonoma-raceway"), + wantsErr: true, + }, + { + name: "file with invalid file format", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:32:32", + }, + baseRepo: ghrepo.New("ttran112", "ttrain211"), + wantsErr: true, }, { name: "file with invalid line number", opts: BrowseOptions{ - SelectorArg: "path/to/file.txt:32:32", + SelectorArg: "path/to/file.txt:32a", }, baseRepo: ghrepo.New("ttran112", "ttrain211"), wantsErr: true, @@ -258,7 +283,7 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("github", "ThankYouGitHub"), wantsErr: false, - expectedURL: "https://github.com/github/ThankYouGitHub/tree/first-browse-pull/browse.go#L32", + expectedURL: "https://github.com/github/ThankYouGitHub/blob/first-browse-pull/browse.go?plain=1#L32", }, { name: "no browser with branch file and line number", @@ -269,7 +294,7 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("mislav", "will_paginate"), wantsErr: false, - expectedURL: "https://github.com/mislav/will_paginate/tree/3-0-stable/init.rb#L6", + expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6", }, } @@ -313,41 +338,3 @@ func Test_runBrowse(t *testing.T) { }) } } - -func Test_parseFileArg(t *testing.T) { - tests := []struct { - name string - arg string - errorExpected bool - expectedFileArg string - stderrExpected string - }{ - { - name: "non line number", - arg: "main.go", - errorExpected: false, - expectedFileArg: "main.go", - }, - { - name: "line number", - arg: "main.go:32", - errorExpected: false, - expectedFileArg: "main.go#L32", - }, - { - name: "non line number error", - arg: "ma:in.go", - errorExpected: true, - stderrExpected: "invalid line number after colon\nUse 'gh browse --help' for more information about browse\n", - }, - } - for _, tt := range tests { - fileArg, err := parseFileArg(tt.arg) - if tt.errorExpected { - assert.Equal(t, err.Error(), tt.stderrExpected) - } else { - assert.Equal(t, err, nil) - assert.Equal(t, tt.expectedFileArg, fileArg) - } - } -} diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 0ea822e8c..ad92a8f14 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -30,6 +30,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { will be forwarded to the %[1]sgh-%[1]s executable of the extension. An extension cannot override any of the core gh commands. + + See the list of available extensions at `, "`"), Aliases: []string{"extensions"}, } @@ -67,9 +69,25 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { }, }, &cobra.Command{ - Use: "install ", + Use: "install ", Short: "Install a gh extension from a repository", - Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), + Long: heredoc.Doc(` + Install a GitHub repository locally as a GitHub CLI extension. + + The repository argument can be specified in "owner/repo" format as well as a full URL. + The URL format is useful when the repository is not hosted on github.com. + + To install an extension in development from the current directory, use "." as the + value of the repository argument. + + See the list of available extensions at + `), + Example: heredoc.Doc(` + $ gh extension install owner/gh-extension + $ gh extension install https://git.example.com/owner/gh-extension + $ gh extension install . + `), + Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), RunE: func(cmd *cobra.Command, args []string) error { if args[0] == "." { wd, err := os.Getwd() @@ -83,16 +101,12 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } + if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { return err } - cfg, err := f.Config() - if err != nil { - return err - } - protocol, _ := cfg.Get(repo.RepoHost(), "git_protocol") - return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut) + return m.Install(repo) }, }, func() *cobra.Command { diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 9dc46b3ba..fac5d0e9a 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -3,14 +3,17 @@ package extension import ( "io" "io/ioutil" + "net/http" "os" "strings" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -39,13 +42,13 @@ func TestNewCmdExtension(t *testing.T) { em.ListFunc = func(bool) []extensions.Extension { return []extensions.Extension{} } - em.InstallFunc = func(s string, out, errOut io.Writer) error { + em.InstallFunc = func(_ ghrepo.Interface) error { return nil } return func(t *testing.T) { installCalls := em.InstallCalls() assert.Equal(t, 1, len(installCalls)) - assert.Equal(t, "https://github.com/owner/gh-some-ext.git", installCalls[0].URL) + assert.Equal(t, "gh-some-ext", installCalls[0].InterfaceMoqParam.RepoName()) listCalls := em.ListCalls() assert.Equal(t, 1, len(listCalls)) } @@ -281,12 +284,19 @@ func TestNewCmdExtension(t *testing.T) { assertFunc = tt.managerStubs(em) } + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + f := cmdutil.Factory{ Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, IOStreams: ios, ExtensionManager: em, + HttpClient: func() (*http.Client, error) { + return &client, nil + }, } cmd := NewCmdExtension(&f) diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go new file mode 100644 index 000000000..6f93f2303 --- /dev/null +++ b/pkg/cmd/extension/http.go @@ -0,0 +1,114 @@ +package extension + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) { + path := fmt.Sprintf("repos/%s/%s/contents/%s", + repo.RepoOwner(), repo.RepoName(), repo.RepoName()) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return + } + + resp, err := httpClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return + } + + if resp.StatusCode > 299 { + err = api.HandleHTTPError(resp) + return + } + + hs = true + return +} + +type releaseAsset struct { + Name string + APIURL string `json:"url"` +} + +type release struct { + Tag string `json:"tag_name"` + Assets []releaseAsset +} + +// downloadAsset downloads a single asset to the given file path. +func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) error { + req, err := http.NewRequest("GET", asset.APIURL, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/octet-stream") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} + +// fetchLatestRelease finds the latest published release for a repository. +func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { + path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName()) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r release + err = json.Unmarshal(b, &r) + if err != nil { + return nil, err + } + + return &r, nil +} diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index b488704e5..7e1f403e2 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "io/ioutil" + "net/http" "os" "os/exec" "path" @@ -14,10 +15,14 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/findsh" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/safeexec" + "gopkg.in/yaml.v3" ) type Manager struct { @@ -25,17 +30,32 @@ type Manager struct { lookPath func(string) (string, error) findSh func() (string, error) newCommand func(string, ...string) *exec.Cmd + platform func() string + client *http.Client + config config.Config + io *iostreams.IOStreams } -func NewManager() *Manager { +func NewManager(io *iostreams.IOStreams) *Manager { return &Manager{ dataDir: config.DataDir, lookPath: safeexec.LookPath, findSh: findsh.Find, newCommand: exec.Command, + platform: func() string { + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + }, } } +func (m *Manager) SetConfig(cfg config.Config) { + m.config = cfg +} + +func (m *Manager) SetClient(client *http.Client) { + m.client = client +} + func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { if len(args) == 0 { return false, errors.New("too few arguments in list") @@ -172,7 +192,104 @@ func (m *Manager) InstallLocal(dir string) error { return makeSymlink(dir, targetLink) } -func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error { +type binManifest struct { + Owner string + Name string + Host string + Tag string + // TODO I may end up not using this; just thinking ahead to local installs + Path string +} + +func (m *Manager) Install(repo ghrepo.Interface) error { + isBin, err := isBinExtension(m.client, repo) + if err != nil { + return fmt.Errorf("could not check for binary extension: %w", err) + } + if isBin { + return m.installBin(repo) + } + + hs, err := hasScript(m.client, repo) + if err != nil { + return err + } + if !hs { + // TODO open an issue hint, here? + return errors.New("extension is uninstallable: missing executable") + } + + protocol, _ := m.config.Get(repo.RepoHost(), "git_protocol") + return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut) +} + +func (m *Manager) installBin(repo ghrepo.Interface) error { + var r *release + r, err := fetchLatestRelease(m.client, repo) + if err != nil { + return err + } + + suffix := m.platform() + var asset *releaseAsset + for _, a := range r.Assets { + if strings.HasSuffix(a.Name, suffix) { + asset = &a + break + } + } + + if asset == nil { + return fmt.Errorf("%s unsupported for %s. Open an issue: `gh issue create -R %s/%s -t'Support %s'`", + repo.RepoName(), + suffix, repo.RepoOwner(), repo.RepoName(), suffix) + } + + name := repo.RepoName() + targetDir := filepath.Join(m.installDir(), name) + // TODO clean this up if function errs? + err = os.MkdirAll(targetDir, 0755) + if err != nil { + return fmt.Errorf("failed to create installation directory: %w", err) + } + + binPath := filepath.Join(targetDir, name) + + err = downloadAsset(m.client, *asset, binPath) + if err != nil { + return fmt.Errorf("failed to download asset %s: %w", asset.Name, err) + } + + manifest := binManifest{ + Name: name, + Owner: repo.RepoOwner(), + Host: repo.RepoHost(), + Path: binPath, + Tag: r.Tag, + } + + bs, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to serialize manifest: %w", err) + } + + manifestPath := filepath.Join(targetDir, "manifest.yml") + + f, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open manifest for writing: %w", err) + } + defer f.Close() + + _, err = f.Write(bs) + if err != nil { + return fmt.Errorf("failed write manifest file: %w", err) + } + + return nil +} + +func (m *Manager) installGit(cloneURL string, stdout, stderr io.Writer) error { exe, err := m.lookPath("git") if err != nil { return err @@ -346,3 +463,77 @@ func readPathFromFile(path string) (string, error) { n, err := f.Read(b) return strings.TrimSpace(string(b[:n])), err } + +func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) { + var r *release + r, err = fetchLatestRelease(client, repo) + if err != nil { + httpErr, ok := err.(api.HTTPError) + if ok && httpErr.StatusCode == 404 { + err = nil + return + } + return + } + + for _, a := range r.Assets { + dists := possibleDists() + for _, d := range dists { + if strings.HasSuffix(a.Name, d) { + isBin = true + break + } + } + } + + return +} + +func possibleDists() []string { + return []string{ + "aix-ppc64", + "android-386", + "android-amd64", + "android-arm", + "android-arm64", + "darwin-amd64", + "darwin-arm64", + "dragonfly-amd64", + "freebsd-386", + "freebsd-amd64", + "freebsd-arm", + "freebsd-arm64", + "illumos-amd64", + "ios-amd64", + "ios-arm64", + "js-wasm", + "linux-386", + "linux-amd64", + "linux-arm", + "linux-arm64", + "linux-mips", + "linux-mips64", + "linux-mips64le", + "linux-mipsle", + "linux-ppc64", + "linux-ppc64le", + "linux-riscv64", + "linux-s390x", + "netbsd-386", + "netbsd-amd64", + "netbsd-arm", + "netbsd-arm64", + "openbsd-386", + "openbsd-amd64", + "openbsd-arm", + "openbsd-arm64", + "openbsd-mips64", + "plan9-386", + "plan9-amd64", + "plan9-arm", + "solaris-amd64", + "windows-386", + "windows-amd64", + "windows-arm", + } +} diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 2fd458bf1..d78f4c2e6 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io/ioutil" + "net/http" "os" "os/exec" "path/filepath" @@ -11,7 +12,12 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) func TestHelperProcess(t *testing.T) { @@ -28,7 +34,7 @@ func TestHelperProcess(t *testing.T) { os.Exit(0) } -func newTestManager(dir string) *Manager { +func newTestManager(dir string, client *http.Client, io *iostreams.IOStreams) *Manager { return &Manager{ dataDir: func() string { return dir }, lookPath: func(exe string) (string, error) { return exe, nil }, @@ -39,6 +45,12 @@ func newTestManager(dir string) *Manager { cmd.Env = []string{"GH_WANT_HELPER_PROCESS=1"} return cmd }, + config: config.NewBlankConfig(), + io: io, + client: client, + platform: func() string { + return "windows-amd64" + }, } } @@ -47,7 +59,7 @@ func TestManager_List(t *testing.T) { assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) exts := m.List(false) assert.Equal(t, 2, len(exts)) assert.Equal(t, "hello", exts[0].Name()) @@ -59,7 +71,7 @@ func TestManager_Dispatch(t *testing.T) { extPath := filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello") assert.NoError(t, stubExtension(extPath)) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -80,7 +92,7 @@ func TestManager_Remove(t *testing.T) { assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) err := m.Remove("hello") assert.NoError(t, err) @@ -96,7 +108,7 @@ func TestManager_Upgrade_AllExtensions(t *testing.T) { assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -121,7 +133,7 @@ func TestManager_Upgrade_RemoteExtension(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -141,7 +153,7 @@ func TestManager_Upgrade_LocalExtension(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -158,7 +170,7 @@ func TestManager_Upgrade_Force(t *testing.T) { assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -180,7 +192,7 @@ func TestManager_Upgrade_Force(t *testing.T) { func TestManager_Upgrade_NoExtensions(t *testing.T) { tempDir := t.TempDir() - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -190,24 +202,153 @@ func TestManager_Upgrade_NoExtensions(t *testing.T) { assert.Equal(t, "", stderr.String()) } -func TestManager_Install(t *testing.T) { +func TestManager_Install_git(t *testing.T) { tempDir := t.TempDir() - m := newTestManager(tempDir) - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Install("https://github.com/owner/gh-some-ext.git", stdout, stderr) + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + io, _, stdout, stderr := iostreams.Test() + + m := newTestManager(tempDir, &client, io) + + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "not-a-binary", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"), + httpmock.StringResponse("script")) + + repo := ghrepo.New("owner", "gh-some-ext") + + err := m.Install(repo) assert.NoError(t, err) assert.Equal(t, fmt.Sprintf("[git clone https://github.com/owner/gh-some-ext.git %s]\n", filepath.Join(tempDir, "extensions", "gh-some-ext")), stdout.String()) assert.Equal(t, "", stderr.String()) } +func TestManager_Install_binary_unsupported(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-linux-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.1", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-linux-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + + io, _, _, _ := iostreams.Test() + tempDir := t.TempDir() + + m := newTestManager(tempDir, &client, io) + + err := m.Install(repo) + assert.Error(t, err) + + errText := "gh-bin-ext unsupported for windows-amd64. Open an issue: `gh issue create -R owner/gh-bin-ext -t'Support windows-amd64'`" + + assert.Equal(t, errText, err.Error()) +} + +func TestManager_Install_binary(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.1", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "release/cool"), + httpmock.StringResponse("FAKE BINARY")) + + io, _, _, _ := iostreams.Test() + tempDir := t.TempDir() + + m := newTestManager(tempDir, &client, io) + + err := m.Install(repo) + assert.NoError(t, err) + + manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/manifest.yml")) + assert.NoError(t, err) + + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + assert.NoError(t, err) + + assert.Equal(t, binManifest{ + Name: "gh-bin-ext", + Owner: "owner", + Host: "example.com", + Tag: "v1.0.1", + Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext"), + }, bm) + + fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext")) + assert.NoError(t, err) + + assert.Equal(t, "FAKE BINARY", string(fakeBin)) +} + func TestManager_Create(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() assert.NoError(t, os.Chdir(tempDir)) t.Cleanup(func() { _ = os.Chdir(oldWd) }) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) err := m.Create("gh-test") assert.NoError(t, err) files, err := ioutil.ReadDir(filepath.Join(tempDir, "gh-test")) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index a373f087d..64cb2f0a7 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/context" @@ -31,6 +32,7 @@ func New(appVersion string) *cmdutil.Factory { f.Remotes = remotesFunc(f) // Depends on Config f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Browser = browser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams return f } @@ -187,6 +189,25 @@ func branchFunc() func() (string, error) { } } +func extensionManager(f *cmdutil.Factory) *extension.Manager { + em := extension.NewManager(f.IOStreams) + + cfg, err := f.Config() + if err != nil { + return em + } + em.SetConfig(cfg) + + client, err := f.HttpClient() + if err != nil { + return em + } + + em.SetClient(api.NewCachedClient(client, time.Second*30)) + + return em +} + func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io := iostreams.System() cfg, err := f.Config() diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index 905615256..a992b553d 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -171,7 +171,7 @@ func searchIssues(client *api.Client, repo ghrepo.Interface, filters prShared.Fi "query": searchQuery, } - ic := api.IssuesAndTotalCount{} + ic := api.IssuesAndTotalCount{SearchCapped: limit > 1000} loop: for { diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 48d76d23e..2db0815b4 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -166,6 +166,9 @@ func listRun(opts *ListOptions) error { return opts.Exporter.Write(opts.IO, listResult.Issues) } + if listResult.SearchCapped { + fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum") + } if isTerminal { title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, !filterOptions.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) diff --git a/pkg/cmd/pr/list/http.go b/pkg/cmd/pr/list/http.go index 621853aaf..5cf6a410e 100644 --- a/pkg/cmd/pr/list/http.go +++ b/pkg/cmd/pr/list/http.go @@ -10,8 +10,12 @@ import ( "github.com/cli/cli/v2/pkg/githubsearch" ) +func shouldUseSearch(filters prShared.FilterOptions) bool { + return filters.Draft != "" || filters.Author != "" || filters.Assignee != "" || filters.Search != "" || len(filters.Labels) > 0 +} + func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { - if filters.Author != "" || filters.Assignee != "" || filters.Search != "" || len(filters.Labels) > 0 { + if shouldUseSearch(filters) { return searchPullRequests(httpClient, repo, filters, limit) } @@ -177,12 +181,16 @@ func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters q.SetBaseBranch(filters.BaseBranch) } + if filters.Draft != "" { + q.SetDraft(filters.Draft) + } + pageLimit := min(limit, 100) variables := map[string]interface{}{ "q": q.String(), } - res := api.PullRequestAndTotalCount{} + res := api.PullRequestAndTotalCount{SearchCapped: limit > 1000} var check = make(map[int]struct{}) client := api.NewClientFromHTTP(httpClient) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 15c943541..a2cec0e43 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -37,6 +37,7 @@ type ListOptions struct { Author string Assignee string Search string + Draft string } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -46,6 +47,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Browser: f.Browser, } + var draft bool + cmd := &cobra.Command{ Use: "list", Short: "List and filter pull requests in this repository", @@ -74,6 +77,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return &cmdutil.FlagError{Err: fmt.Errorf("invalid value for --limit: %v", opts.LimitResults)} } + if cmd.Flags().Changed("draft") { + opts.Draft = strconv.FormatBool(draft) + } + if runF != nil { return runF(opts) } @@ -92,6 +99,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`") + cmd.Flags().BoolVarP(&draft, "draft", "d", false, "Filter by draft state") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) return cmd @@ -132,12 +141,12 @@ func listRun(opts *ListOptions) error { Labels: opts.Labels, BaseBranch: opts.BaseBranch, Search: opts.Search, + Draft: opts.Draft, Fields: defaultFields, } if opts.Exporter != nil { filters.Fields = opts.Exporter.Fields() } - if opts.WebMode { prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") openURL, err := shared.ListURLWithQuery(prListURL, filters) @@ -166,6 +175,9 @@ func listRun(opts *ListOptions) error { return opts.Exporter.Write(opts.IO, listResult.PullRequests) } + if listResult.SearchCapped { + fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum") + } if opts.IO.IsStdoutTTY() { title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, !filters.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index a912e4cb6..ff55089f0 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -176,39 +176,89 @@ func TestPRList_filteringAssignee(t *testing.T) { } } -func TestPRList_filteringAssigneeLabels(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) +func TestPRList_filteringDraft(t *testing.T) { + tests := []struct { + name string + cli string + expectedQuery string + }{ + { + name: "draft", + cli: "--draft", + expectedQuery: `repo:OWNER/REPO is:pr is:open draft:true`, + }, + { + name: "non-draft", + cli: "--draft=false", + expectedQuery: `repo:OWNER/REPO is:pr is:open draft:false`, + }, + } - _, err := runCommand(http, true, `-l one,two -a hubot`) - if err == nil && err.Error() != "multiple labels with --assignee are not supported" { - t.Fatal(err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestSearch\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, test.expectedQuery, params["q"].(string)) + })) + + _, err := runCommand(http, true, test.cli) + if err != nil { + t.Fatal(err) + } + }) } } func TestPRList_withInvalidLimitFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - _, err := runCommand(http, true, `--limit=0`) - if err == nil && err.Error() != "invalid limit: 0" { - t.Errorf("error running command `issue list`: %v", err) - } + assert.EqualError(t, err, "invalid value for --limit: 0") } func TestPRList_web(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - _, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - output, err := runCommand(http, true, "--web -a peter -l bug -l docs -L 10 -s merged -B trunk") - if err != nil { - t.Errorf("error running command `pr list` with `--web` flag: %v", err) + tests := []struct { + name string + cli string + expectedBrowserURL string + }{ + { + name: "filters", + cli: "-a peter -l bug -l docs -L 10 -s merged -B trunk", + expectedBrowserURL: "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk", + }, + { + name: "draft", + cli: "--draft=true", + expectedBrowserURL: "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Aopen+draft%3Atrue", + }, + { + name: "non-draft", + cli: "--draft=0", + expectedBrowserURL: "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Aopen+draft%3Afalse", + }, } - assert.Equal(t, "", output.String()) - assert.Equal(t, "Opening github.com/OWNER/REPO/pulls in your browser.\n", output.Stderr()) - assert.Equal(t, "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk", output.BrowsedURL) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + output, err := runCommand(http, true, "--web "+test.cli) + if err != nil { + t.Errorf("error running command `pr list` with `--web` flag: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/pulls in your browser.\n", output.Stderr()) + assert.Equal(t, test.expectedBrowserURL, output.BrowsedURL) + }) + } } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 92096e9d8..2333bcfa3 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -157,8 +157,8 @@ type FilterOptions struct { Mention string Milestone string Search string - - Fields []string + Draft string + Fields []string } func (opts *FilterOptions) IsDefault() bool { @@ -241,7 +241,9 @@ func SearchQueryBuild(options FilterOptions) string { if options.Search != "" { q.AddQuery(options.Search) } - + if options.Draft != "" { + q.SetDraft(options.Draft) + } return q.String() } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index fa41ac307..8f3e793e5 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -16,6 +16,7 @@ func Test_listURLWithQuery(t *testing.T) { listURL string options FilterOptions } + tests := []struct { name string args args @@ -34,6 +35,32 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen", wantErr: false, }, + { + name: "draft", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Draft: "true", + }, + }, + want: "https://example.com/path?q=is%3Apr+is%3Aopen+draft%3Atrue", + wantErr: false, + }, + { + name: "non-draft", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Draft: "false", + }, + }, + want: "https://example.com/path?q=is%3Apr+is%3Aopen+draft%3Afalse", + wantErr: false, + }, { name: "all", args: args{ diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index a99e471cc..1f74a8188 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -251,7 +251,7 @@ func createRun(opts *CreateOptions) error { if !isVisibilityPassed { newVisibility, err := getVisibility() if err != nil { - return nil + return err } visibility = newVisibility } diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 7387ca84d..af6f505c0 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -171,6 +171,9 @@ func listRun(opts *ListOptions) error { tp.EndRow() } + if listResult.FromSearch && opts.Limit > 1000 { + fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum") + } if opts.IO.IsStdoutTTY() { hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Language != "" || filter.Topic != "" title := listHeader(listResult.Owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) diff --git a/pkg/cmd/repo/sync/git.go b/pkg/cmd/repo/sync/git.go index ffb9a4179..1ec86b053 100644 --- a/pkg/cmd/repo/sync/git.go +++ b/pkg/cmd/repo/sync/git.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/pkg/iostreams" ) type gitClient interface { @@ -20,7 +21,9 @@ type gitClient interface { ResetHard(string) error } -type gitExecuter struct{} +type gitExecuter struct { + io *iostreams.IOStreams +} func (g *gitExecuter) BranchRemote(branch string) (string, error) { args := []string{"rev-parse", "--symbolic-full-name", "--abbrev-ref", fmt.Sprintf("%s@{u}", branch)} @@ -64,11 +67,14 @@ func (g *gitExecuter) CurrentBranch() (string, error) { } func (g *gitExecuter) Fetch(remote, ref string) error { - args := []string{"fetch", remote, ref} + args := []string{"fetch", "-q", remote, ref} cmd, err := git.GitCommand(args...) if err != nil { return err } + cmd.Stdin = g.io.In + cmd.Stdout = g.io.Out + cmd.Stderr = g.io.ErrOut return cmd.Run() } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index c4dc72695..8a71161f5 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -34,7 +34,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, - Git: &gitExecuter{}, + Git: &gitExecuter{io: f.IOStreams}, } cmd := &cobra.Command{ @@ -134,6 +134,11 @@ func syncLocalRepo(opts *SyncOptions) error { } } + // Git fetch might require input from user, so do it before starting progress indicator. + if err := opts.Git.Fetch(remote, fmt.Sprintf("refs/heads/%s", opts.Branch)); err != nil { + return err + } + opts.IO.StartProgressIndicator() err = executeLocalRepoSync(srcRepo, remote, opts) opts.IO.StopProgressIndicator() @@ -232,10 +237,6 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt branch := opts.Branch useForce := opts.Force - if err := git.Fetch(remote, fmt.Sprintf("refs/heads/%s", branch)); err != nil { - return err - } - hasLocalBranch := git.HasLocalBranch(branch) if hasLocalBranch { branchRemote, err := git.BranchRemote(branch) diff --git a/pkg/extensions/extension.go b/pkg/extensions/extension.go index 54379ec7f..4e9ce89b5 100644 --- a/pkg/extensions/extension.go +++ b/pkg/extensions/extension.go @@ -2,6 +2,8 @@ package extensions import ( "io" + + "github.com/cli/cli/v2/internal/ghrepo" ) //go:generate moq -rm -out extension_mock.go . Extension @@ -16,7 +18,7 @@ type Extension interface { //go:generate moq -rm -out manager_mock.go . ExtensionManager type ExtensionManager interface { List(includeMetadata bool) []Extension - Install(url string, stdout, stderr io.Writer) error + Install(ghrepo.Interface) error InstallLocal(dir string) error Upgrade(name string, force bool, stdout, stderr io.Writer) error Remove(name string) error diff --git a/pkg/extensions/manager_mock.go b/pkg/extensions/manager_mock.go index 157262698..96d76cdf7 100644 --- a/pkg/extensions/manager_mock.go +++ b/pkg/extensions/manager_mock.go @@ -4,6 +4,7 @@ package extensions import ( + "github.com/cli/cli/v2/internal/ghrepo" "io" "sync" ) @@ -24,7 +25,7 @@ var _ ExtensionManager = &ExtensionManagerMock{} // DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { // panic("mock out the Dispatch method") // }, -// InstallFunc: func(url string, stdout io.Writer, stderr io.Writer) error { +// InstallFunc: func(interfaceMoqParam ghrepo.Interface) error { // panic("mock out the Install method") // }, // InstallLocalFunc: func(dir string) error { @@ -53,7 +54,7 @@ type ExtensionManagerMock struct { DispatchFunc func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) // InstallFunc mocks the Install method. - InstallFunc func(url string, stdout io.Writer, stderr io.Writer) error + InstallFunc func(interfaceMoqParam ghrepo.Interface) error // InstallLocalFunc mocks the InstallLocal method. InstallLocalFunc func(dir string) error @@ -87,12 +88,8 @@ type ExtensionManagerMock struct { } // Install holds details about calls to the Install method. Install []struct { - // URL is the url argument value. - URL string - // Stdout is the stdout argument value. - Stdout io.Writer - // Stderr is the stderr argument value. - Stderr io.Writer + // InterfaceMoqParam is the interfaceMoqParam argument value. + InterfaceMoqParam ghrepo.Interface } // InstallLocal holds details about calls to the InstallLocal method. InstallLocal []struct { @@ -205,37 +202,29 @@ func (mock *ExtensionManagerMock) DispatchCalls() []struct { } // Install calls InstallFunc. -func (mock *ExtensionManagerMock) Install(url string, stdout io.Writer, stderr io.Writer) error { +func (mock *ExtensionManagerMock) Install(interfaceMoqParam ghrepo.Interface) error { if mock.InstallFunc == nil { panic("ExtensionManagerMock.InstallFunc: method is nil but ExtensionManager.Install was just called") } callInfo := struct { - URL string - Stdout io.Writer - Stderr io.Writer + InterfaceMoqParam ghrepo.Interface }{ - URL: url, - Stdout: stdout, - Stderr: stderr, + InterfaceMoqParam: interfaceMoqParam, } mock.lockInstall.Lock() mock.calls.Install = append(mock.calls.Install, callInfo) mock.lockInstall.Unlock() - return mock.InstallFunc(url, stdout, stderr) + return mock.InstallFunc(interfaceMoqParam) } // InstallCalls gets all the calls that were made to Install. // Check the length with: // len(mockedExtensionManager.InstallCalls()) func (mock *ExtensionManagerMock) InstallCalls() []struct { - URL string - Stdout io.Writer - Stderr io.Writer + InterfaceMoqParam ghrepo.Interface } { var calls []struct { - URL string - Stdout io.Writer - Stderr io.Writer + InterfaceMoqParam ghrepo.Interface } mock.lockInstall.RLock() calls = mock.calls.Install diff --git a/pkg/githubsearch/query.go b/pkg/githubsearch/query.go index f25707710..a8f3005a9 100644 --- a/pkg/githubsearch/query.go +++ b/pkg/githubsearch/query.go @@ -54,6 +54,7 @@ type Query struct { forkState string visibility string isArchived *bool + draft string } func (q *Query) InRepository(nameWithOwner string) { @@ -139,6 +140,10 @@ func (q *Query) SetArchived(isArchived bool) { q.isArchived = &isArchived } +func (q *Query) SetDraft(draft string) { + q.draft = draft +} + func (q *Query) String() string { var qs string @@ -198,6 +203,9 @@ func (q *Query) String() string { if q.headBranch != "" { qs += fmt.Sprintf("head:%s ", quote(q.headBranch)) } + if q.draft != "" { + qs += fmt.Sprintf("draft:%v ", q.draft) + } if q.sort != "" { qs += fmt.Sprintf("sort:%s ", q.sort)