Merge remote-tracking branch 'origin' into 3704-credential-helper
This commit is contained in:
commit
78b35b7b6e
28 changed files with 840 additions and 203 deletions
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ type IssuesPayload struct {
|
|||
}
|
||||
|
||||
type IssuesAndTotalCount struct {
|
||||
Issues []Issue
|
||||
TotalCount int
|
||||
Issues []Issue
|
||||
TotalCount int
|
||||
SearchCapped bool
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type PullRequestsPayload struct {
|
|||
type PullRequestAndTotalCount struct {
|
||||
TotalCount int
|
||||
PullRequests []PullRequest
|
||||
SearchCapped bool
|
||||
}
|
||||
|
||||
type PullRequest struct {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ commands is:
|
|||
pkg/cmd/<command>/<subcommand>/<subcommand>.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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
will be forwarded to the %[1]sgh-<extname>%[1]s executable of the extension.
|
||||
|
||||
An extension cannot override any of the core gh commands.
|
||||
|
||||
See the list of available extensions at <https://github.com/topics/gh-extension>
|
||||
`, "`"),
|
||||
Aliases: []string{"extensions"},
|
||||
}
|
||||
|
|
@ -67,9 +69,25 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "install <repo>",
|
||||
Use: "install <repository>",
|
||||
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 <https://github.com/topics/gh-extension>
|
||||
`),
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
114
pkg/cmd/extension/http.go
Normal file
114
pkg/cmd/extension/http.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ func createRun(opts *CreateOptions) error {
|
|||
if !isVisibilityPassed {
|
||||
newVisibility, err := getVisibility()
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
visibility = newVisibility
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue